SF映画っぽいエクスプローラ「Exp3D 1.3」



画像
メイン画面

・ダウンロードされる方はこちら。↓


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Exp3D 1.3</title>
    <style>
        :root {
            --cyan: #00ffff;
            --magenta: #ff00ff;
            --bg: #050508;
            --panel: rgba(10, 15, 20, 0.95);
            --menu-bg: rgba(15, 20, 25, 0.98);
        }
        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 {
            width: 280px; background: var(--panel); border-right: 1px solid var(--cyan);
            display: flex; flex-direction: column; z-index: 20; backdrop-filter: blur(10px);
        }
        #sb-header {
            padding: 15px; color: var(--cyan); font-weight: bold; letter-spacing: 2px;
            border-bottom: 1px solid rgba(0,255,255,0.3); background: linear-gradient(90deg, rgba(0,255,255,0.1), transparent);
        }
        #tree-container { flex: 1; overflow-y: auto; padding: 10px; }
        .tree-node {
            display: flex; align-items: center; padding: 4px 8px; cursor: pointer; color: #888;
            font-size: 13px; transition: 0.2s; border-left: 2px solid transparent;
        }
        .tree-node:hover { background: rgba(0,255,255,0.1); color: #fff; }
        .icon-3d {
            width: 12px; height: 14px; margin-right: 8px; position: relative;
            transform-style: preserve-3d; transform: rotateX(-10deg) rotateY(25deg);
            background: rgba(255,255,255,0.1); border: 1px solid currentColor; flex-shrink: 0;
        }
        .icon-folder { color: var(--cyan); }
        .icon-file { color: var(--magenta); border-radius: 2px; }

        /* Main View */
        #main { flex: 1; position: relative; overflow: hidden; }
        #canvas-wrap { width: 100%; height: 100%; position: absolute; top:0; left:0; }

        /* HUD */
        #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; }
        #hud-label {
            position: absolute; display: none; color: var(--cyan); font-family: 'Courier New'; font-weight: bold;
            font-size: 18px; text-shadow: 0 0 5px var(--cyan); white-space: nowrap; pointer-events: none;
            background: rgba(0,0,0,0.8); padding: 4px 8px; border: 1px solid var(--cyan);
        }

        /* Context Menu (Fixed) */
        .ctx-menu {
            position: fixed; 
            display: none; flex-direction: column;
            background: var(--menu-bg); border: 1px solid var(--cyan);
            box-shadow: 0 0 20px rgba(0,255,255,0.2); min-width: 200px;
            z-index: 9999; pointer-events: auto; padding: 5px 0;
        }
        .ctx-item {
            padding: 8px 15px; cursor: pointer; color: #ccc; font-size: 13px;
            display: flex; justify-content: space-between; transition: 0.2s;
        }
        .ctx-item:hover { background: var(--cyan); color: #000; }
        .ctx-item.disabled { color: #555; pointer-events: none; }
        .ctx-sep { height: 1px; background: #333; margin: 4px 0; }
        .shortcut { font-size: 10px; opacity: 0.7; }

        /* Shelf */
        #shelf {
            position: absolute; bottom: 0; left: 0; right: 0; height: 150px;
            background: linear-gradient(to top, rgba(0,20,30,0.98), transparent);
            border-top: 2px solid var(--magenta); display: flex; align-items: center; justify-content: flex-start;
            padding: 0 20px; gap: 15px; z-index: 30; transition: bottom 0.3s;
        }
        #shelf.closed { bottom: -150px; }
        #shelf-tab {
            position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
            background: var(--magenta); color: #000; padding: 2px 20px; font-weight: bold;
            cursor: pointer; clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%); z-index: 31; transition: bottom 0.3s;
        }
        #shelf.closed + #shelf-tab { bottom: 0; }
        .shelf-item {
            width: 80px; height: 100px; 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); position: relative; flex-shrink: 0;
        }
        .shelf-img { font-size: 30px; margin-bottom: 5px; }
        .shelf-text { font-size: 10px; text-align: center; width: 90%; overflow: hidden; text-overflow: ellipsis; }

        /* Preview Modal */
        #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;
            perspective: 1000px; transition: opacity 0.4s;
        }
        #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.15);
            transform: rotateY(90deg) scale(0.5);
            transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); 
        }
        #preview.active #p-box { transform: rotateY(0deg) scale(1); }

        #p-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; font-weight: bold; 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; font-weight: bold; transition: 0.2s;
        }
        .p-btn:hover { background: var(--cyan); color: #000; }
        .p-btn-save { border-color: #0f0; color: #0f0; }
        .p-btn-save:hover { background: #0f0; color: #000; }

        .hex-view { 
            width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; 
            font-family: 'Courier New', monospace; white-space: pre; overflow: auto; border: none; outline: none; resize: none;
        }
        
        video, audio { max-width: 100%; max-height: 100%; outline: none; }

        .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% 4px; z-index: 9999; opacity: 0.3;
        }
    </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>

        <div id="main">
            <div id="canvas-wrap"></div>
            
            <div id="hud">
                <canvas id="hud-canvas"></canvas>
                <div id="hud-label">FILENAME</div>
            </div>
            
            <div id="ctx-obj" class="ctx-menu">
                <div class="ctx-item" id="cm-open">Open <span class="shortcut">DblClk</span></div>
                <div class="ctx-sep"></div>
                <div class="ctx-item" id="cm-copy">Copy <span class="shortcut">Ctrl+C</span></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-sep"></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 <span class="shortcut">Ctrl+V</span></div>
                <div class="ctx-sep"></div>
                <div class="ctx-item" id="cm-up">Up Directory</div>
                <div class="ctx-item" id="cm-refresh">Refresh Scan</div>
            </div>

            <div id="shelf">
                <div style="color:var(--magenta); font-size:12px; opacity:0.6;">[ DRAG FILES & FOLDERS HERE ]</div>
            </div>
            <div id="shelf-tab">▼ DOCK</div>
        </div>
    </div>

    <div id="preview">
        <div id="p-box">
            <div id="p-head">
                <span id="p-title">FILENAME</span>
                <div>
                    <button id="p-save" class="p-btn p-btn-save" style="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';

        // --- Config & Global State ---
        const CONFIG = { colFolder: 0x00ffff, colFile: 0xff00ff, grid: 0x002233 };
        const state = {
            rootHandle: null, currentHandle: null, currentPath: [],
            objects: [], shelfData: [], dragging: null, hovered: null, selected: null,
            clipboard: null, 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 osc = sfx.ctx.createOscillator();
                const gain = sfx.ctx.createGain();
                osc.connect(gain); gain.connect(sfx.ctx.destination);
                
                if(type==='click'){ osc.frequency.setValueAtTime(600,t); osc.frequency.linearRampToValueAtTime(100,t+0.1); gain.gain.setValueAtTime(0.1,t); gain.gain.exponentialRampToValueAtTime(0.001,t+0.1); osc.start(t); osc.stop(t+0.1); }
                else if(type==='hover'){ osc.frequency.setValueAtTime(400,t); osc.frequency.linearRampToValueAtTime(600,t+0.05); gain.gain.setValueAtTime(0.02,t); gain.gain.exponentialRampToValueAtTime(0.001,t+0.05); osc.start(t); osc.stop(t+0.05); }
                else if(type==='open'){ osc.type='triangle'; osc.frequency.setValueAtTime(200,t); osc.frequency.linearRampToValueAtTime(600,t+0.2); gain.gain.setValueAtTime(0.1,t); gain.gain.exponentialRampToValueAtTime(0.001,t+0.2); osc.start(t); osc.stop(t+0.2); }
                else if(type==='lock'){ osc.type='square'; osc.frequency.setValueAtTime(1000,t); gain.gain.setValueAtTime(0.05,t); gain.gain.exponentialRampToValueAtTime(0.001,t+0.1); osc.start(t); osc.stop(t+0.1); }
            }
        };

        // --- Scene Init ---
        const container = document.getElementById('canvas-wrap');
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x050508, 0.02);

        const camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 0.1, 1000);
        camera.position.set(0, 8, 12);

        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(0x404040, 5));
        const dl = new THREE.DirectionalLight(0xffffff, 3);
        dl.position.set(5, 10, 5);
        scene.add(dl);
        scene.add(new THREE.GridHelper(100, 100, CONFIG.grid, 0x001122));

        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.maxPolarAngle = Math.PI / 2.1;

        const filesGroup = new THREE.Group();
        scene.add(filesGroup);

        // --- Reticle ---
        const reticleGroup = new THREE.Group();
        scene.add(reticleGroup);
        reticleGroup.visible = false;
        
        const rRing = new THREE.Mesh(new THREE.RingGeometry(1.2, 1.25, 32), new THREE.MeshBasicMaterial({color:0xffff00, side:THREE.DoubleSide}));
        reticleGroup.add(rRing);
        const bGeo = new THREE.BufferGeometry();
        const bs=1.5, bl=0.4;
        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:0xffff00})));

        // --- File System ---
        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) { console.log(e); }
        };

        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 = [];
                deselect();
                const entries = [];
                for await (const e of handle.values()) entries.push(e);
                layoutFiles(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);
                });
            }
        }

        function animateTransition(dir, cb) {
            if(state.isTransitioning) return;
            state.isTransitioning = true;
            sfx.play('open');
            const startT = Date.now();
            const startS = filesGroup.scale.x;
            const targetS = dir==='in'?3.0:0.1;

            function loop() {
                const p = Math.min((Date.now()-startT)/400, 1);
                const s = startS + (targetS - startS) * (p*p);
                filesGroup.scale.set(s,s,s);
                filesGroup.children.forEach(c => c.material.opacity = 0.9*(1-p));
                
                if(p<1) requestAnimationFrame(loop);
                else cb().then(() => {
                    filesGroup.scale.set(dir==='in'?0.1:3.0, dir==='in'?0.1:3.0, dir==='in'?0.1:3.0);
                    const startT2 = Date.now();
                    const startS2 = filesGroup.scale.x;
                    function loop2() {
                        const p2 = Math.min((Date.now()-startT2)/400, 1);
                        const s2 = startS2 + (1.0 - startS2) * (1-(1-p2)*(1-p2));
                        filesGroup.scale.set(s2,s2,s2);
                        filesGroup.children.forEach(c => c.material.opacity = 0.9*p2);
                        if(p2<1) requestAnimationFrame(loop2);
                        else state.isTransitioning=false;
                    }
                    loop2();
                });
            }
            loop();
        }

        function layoutFiles(entries) {
            const cols = Math.ceil(Math.sqrt(entries.length));
            const spacing = 3, offset = (cols*3)/2;
            entries.forEach((e,i) => {
                createMonolith(e, (i%cols)*3-offset, Math.floor(i/cols)*3-offset);
            });
        }

        function createMonolith(handle, x, z) {
            const isF = handle.kind==='directory';
            const col = isF ? CONFIG.colFolder : CONFIG.colFile;
            const geo = new THREE.BoxGeometry(1.6, 2.2, 0.1);
            const mat = new THREE.MeshPhysicalMaterial({
                color:col, metalness:0.1, roughness:0.1, transmission:0.5, opacity:0.9, transparent:true, emissive:col, emissiveIntensity:0.2
            });
            const mesh = new THREE.Mesh(geo, mat);
            mesh.position.set(x, 1.5, z);
            mesh.userData = { handle, isFolder:isF, isRoot:true };

            const edge = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({color:0xffffff, transparent:true, opacity:0.6}));
            mesh.add(edge);
            
            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=256;
            const ctx = cvs.getContext('2d');
            ctx.fillStyle=isF?'#00ffff':'#ff00ff'; ctx.font='80px Arial'; ctx.textAlign='center';
            ctx.fillText(isF?'📂':'📄',128,100);
            ctx.fillStyle='#fff'; ctx.font='bold 28px Courier New';
            ctx.fillText(handle.name.substring(0,10),128,180);
            const sp = new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(cvs)}));
            sp.scale.set(1.5,1.5,1); sp.position.z=0.15;
            mesh.add(sp);

            filesGroup.add(mesh);
            state.objects.push(mesh);
        }

        // --- Interaction ---
        const raycaster = new THREE.Raycaster();
        const mouse = new THREE.Vector2();

        function getRoot(o) { while(o){if(o.userData?.isRoot)return o; o=o.parent;} return null; }
        
        function updateMouse(e) {
            const rect = renderer.domElement.getBoundingClientRect();
            mouse.x = ((e.clientX-rect.left)/rect.width)*2-1;
            mouse.y = -((e.clientY-rect.top)/rect.height)*2+1;
        }

        // IMPORTANT: Prevent clicks on the menu from triggering global pointer events
        document.querySelectorAll('.ctx-menu').forEach(menu => {
            menu.addEventListener('pointerdown', e => e.stopPropagation());
        });

        window.addEventListener('pointerdown', e => {
            sfx.init();
            if(e.button!==0) return;
            
            // If click is not inside canvas (e.g. sidebar or shelf), don't deselect
            if (!e.target.closest('canvas')) return;

            updateMouse(e);
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            
            hideMenus();

            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                if(t) {
                    selectObject(t);
                    state.dragging = { mesh:t, dist:camera.position.distanceTo(t.position) };
                    orbitControls.enabled = false;
                    t.material.opacity=0.5;
                }
            } else {
                deselect();
            }
        });

        window.addEventListener('pointermove', e => {
            updateMouse(e);
            if(state.dragging) {
                raycaster.setFromCamera(mouse, camera);
                const pl = new THREE.Plane(new THREE.Vector3(0,1,0), -1.5);
                const pt = new THREE.Vector3();
                raycaster.ray.intersectPlane(pl, pt);
                if(pt) state.dragging.mesh.position.copy(pt);
                updateReticle();
            } else {
                checkHover();
            }
        });

        window.addEventListener('pointerup', () => {
            if(state.dragging) {
                const m = state.dragging.mesh;
                m.material.opacity = 0.9;
                if(m.position.clone().project(camera).y < -0.5) moveToShelf(m);
                state.dragging=null;
                orbitControls.enabled=true;
            }
        });

        function selectObject(o) {
            state.selected=o;
            reticleGroup.visible=true;
            sfx.play('lock');
            updateReticle();
        }
        function deselect() {
            state.selected=null;
            reticleGroup.visible=false;
        }
        function updateReticle() {
            if(state.selected) {
                reticleGroup.position.copy(state.selected.position);
                reticleGroup.lookAt(camera.position);
            }
        }

        // --- Context Menu (Fixed Logic) ---
        const ctxObj = document.getElementById('ctx-obj');
        const ctxBg = document.getElementById('ctx-bg');

        window.addEventListener('contextmenu', e => {
            e.preventDefault();
            // Don't show custom menu if clicking on sidebar/shelf
            if(!e.target.closest('canvas')) return;

            updateMouse(e);
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            hideMenus();
            sfx.play('click');

            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                if(t) {
                    selectObject(t);
                    showMenu(ctxObj, e.clientX, e.clientY);
                }
            } else {
                deselect();
                document.getElementById('cm-paste').classList.toggle('disabled', !state.clipboard);
                showMenu(ctxBg, e.clientX, e.clientY);
            }
        });

        function showMenu(el, x, y) {
            el.style.display='flex';
            // Adjust position to stay on screen
            if(x+200 > window.innerWidth) x -= 200;
            if(y+250 > window.innerHeight) y -= 250;
            el.style.left=x+'px'; el.style.top=y+'px';
        }
        function hideMenus() { ctxObj.style.display='none'; ctxBg.style.display='none'; }

        // --- Menu Actions (Fixed Handlers) ---
        document.getElementById('cm-open').onclick = () => { if(state.selected) performOpen(state.selected); };
        
        document.getElementById('cm-copy').onclick = () => { 
            if(state.selected) state.clipboard = state.selected.userData.handle; 
            hideMenus(); 
        };
        
        document.getElementById('cm-delete').onclick = async () => {
            if(state.selected && confirm('Delete?')) {
                try {
                    await state.currentHandle.removeEntry(state.selected.userData.handle.name, {recursive:true});
                    enterFolder(state.currentHandle, false);
                } catch(err){ alert('Delete failed'); }
            } 
            hideMenus();
        };

        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) {
                    try {
                        await state.selected.userData.handle.move(n);
                        enterFolder(state.currentHandle, false);
                    } catch(err){ alert('Rename failed'); }
                }
            } 
            hideMenus();
        };

        document.getElementById('cm-prop').onclick = async () => {
            if(state.selected){
                const h = state.selected.userData.handle;
                alert(`Name: ${h.name}\nKind: ${h.kind}`);
            } 
            hideMenus();
        };

        // Bg Actions
        document.getElementById('cm-new').onclick = async () => {
            const n = prompt("Folder Name:");
            if(n) {
                try {
                    await state.currentHandle.getDirectoryHandle(n,{create:true}); 
                    enterFolder(state.currentHandle, false);
                } catch(err){ alert('Create failed'); }
            } 
            hideMenus();
        };

        document.getElementById('cm-paste').onclick = async () => {
            if(state.clipboard && state.clipboard.kind === 'file'){
                try {
                    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);
                } catch(err) { alert('Paste failed'); }
            } else if(state.clipboard) {
                alert("Folder copy not supported by browser security.");
            } 
            hideMenus();
        };

        document.getElementById('cm-up').onclick = () => { goUp(); hideMenus(); };
        document.getElementById('cm-refresh').onclick = () => { enterFolder(state.currentHandle, false); hideMenus(); };


        // --- Double Click ---
        window.addEventListener('dblclick', e => {
            updateMouse(e);
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                if(t) performOpen(t);
            }
        });

        function performOpen(mesh) {
            hideMenus(); // Close any open menus
            if(!mesh) return;
            const h = mesh.userData.handle;
            if(mesh.userData.isFolder) {
                enterFolder(h);
                updateTree(state.rootHandle);
            } else {
                sfx.play('open');
                openPreview(h);
            }
        }

        // --- Preview Logic ---
        const pModal = document.getElementById('preview');
        const pBody = document.getElementById('p-body');
        const btnSave = document.getElementById('p-save');
        let curFile = null;
        let pRenderer, pScene, pFrame;

        async function openPreview(handle) {
            curFile=handle;
            pModal.classList.add('active');
            document.getElementById('p-title').innerText=handle.name;
            pBody.innerHTML='<div style="color:#fff">Loading...</div>';
            btnSave.style.display='none';
            cleanupPreview();

            try {
                const f = await handle.getFile();
                const n = f.name.toLowerCase();
                const url = URL.createObjectURL(f);

                if(n.match(/\.(fbx|glb|gltf|obj)$/)) init3D(url, n.split('.').pop());
                else if(n.match(/\.(png|jpg|webp|gif)$/)) pBody.innerHTML=`<img src="${url}" style="max-width:100%; max-height:100%; object-fit:contain">`;
                else if(n.match(/\.(mp4|webm|mkv)$/)) pBody.innerHTML=`<video src="${url}" controls autoplay></video>`;
                else if(n.match(/\.(mp3|wav|ogg)$/)) pBody.innerHTML=`<div style="text-align:center"><div style="font-size:50px">🎵</div><audio src="${url}" controls></audio></div>`;
                else {
                    // Text / Hex
                    try {
                        const t = await f.text();
                        if(t.includes('\0') && !n.endsWith('.txt')) throw new Error();
                        const ta = document.createElement('textarea');
                        ta.id='editor'; ta.className='hex-view'; ta.value=t;
                        pBody.innerHTML=''; pBody.appendChild(ta);
                        btnSave.style.display='inline-block';
                    } catch(e) {
                        const b = await f.arrayBuffer();
                        const v = new DataView(b);
                        let s = `BINARY (${f.size} B)\n\n`;
                        const l = Math.min(v.byteLength, 1024);
                        for(let i=0; i<l; i+=16) {
                            s+=i.toString(16).padStart(6,'0').toUpperCase()+'  ';
                            for(let j=0; j<16; j++) {
                                if(i+j<l) s+=v.getUint8(i+j).toString(16).padStart(2,'0').toUpperCase()+' ';
                            } s+='\n';
                        }
                        const ta = document.createElement('textarea');
                        ta.className='hex-view'; ta.value=s; ta.readOnly=true;
                        pBody.innerHTML=''; pBody.appendChild(ta);
                    }
                }
            } catch(e){ pBody.innerHTML='Error'; }
        }

        function init3D(url, ext) {
            pBody.innerHTML='';
            const scn = new THREE.Scene(); scn.background=new THREE.Color(0x111111);
            const cam = new THREE.PerspectiveCamera(50, pBody.clientWidth/pBody.clientHeight, 0.1, 1000);
            cam.position.set(0,2,5);
            const rnd = new THREE.WebGLRenderer({antialias:true});
            rnd.setSize(pBody.clientWidth, pBody.clientHeight);
            pBody.appendChild(rnd.domElement);
            const ctr = new OrbitControls(cam, rnd.domElement);
            scn.add(new THREE.AmbientLight(0xffffff, 0.5));
            scn.add(new THREE.DirectionalLight(0xffffff, 2));

            const load = (o) => {
                if(ext==='gltf'||ext==='glb') o=o.scene;
                const b = new THREE.Box3().setFromObject(o);
                const s = 2 / b.getSize(new THREE.Vector3()).length();
                o.scale.setScalar(s); o.position.y = -b.getSize(new THREE.Vector3()).y*s/2;
                scn.add(o);
            };
            if(ext==='fbx') new FBXLoader().load(url, load);
            else if(ext==='obj') new OBJLoader().load(url, load);
            else new GLTFLoader().load(url, load);

            function l(){ pFrame=requestAnimationFrame(l); rnd.render(scn,cam); }
            l(); pRenderer=rnd; pScene=scn;
        }

        function cleanupPreview() {
            if(pFrame) cancelAnimationFrame(pFrame);
            if(pRenderer) { pRenderer.dispose(); pRenderer=null; }
        }
        document.getElementById('p-close').onclick=()=>{sfx.play('click'); pModal.classList.remove('active'); cleanupPreview();};
        btnSave.onclick=async()=>{
            sfx.play('lock');
            const el=document.getElementById('editor');
            if(curFile && el) {
                try {
                    const w=await curFile.createWritable(); await w.write(el.value); await w.close();
                    el.style.background='#003300'; setTimeout(()=>el.style.background='#000',200);
                } catch(e){alert("Error");}
            }
        };

        // --- HUD (Fixed) ---
        const hudC = document.getElementById('hud-canvas');
        const hudX = hudC.getContext('2d');
        const hudL = document.getElementById('hud-label');

        function resizeHUD() { hudC.width=container.clientWidth; hudC.height=container.clientHeight; }
        window.onresize=()=>{ 
            camera.aspect=container.clientWidth/container.clientHeight; camera.updateProjectionMatrix();
            renderer.setSize(container.clientWidth, container.clientHeight);
            composer.setSize(container.clientWidth, container.clientHeight);
            resizeHUD();
        }; resizeHUD();

        function checkHover() {
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            if(hits.length>0) {
                const t=getRoot(hits[0].object);
                if(t) {
                    if(state.hovered!==t) sfx.play('hover');
                    state.hovered=t; document.body.style.cursor='pointer';
                    return;
                }
            }
            state.hovered=null; document.body.style.cursor='default';
        }

        function drawHUD() {
            hudX.clearRect(0,0, hudC.width, hudC.height);
            if(state.selected && reticleGroup.visible) {
                reticleGroup.children[0].rotation.z -= 0.05;
                reticleGroup.children[1].scale.setScalar(1 + Math.sin(Date.now()*0.005)*0.1);
            }
            if(!state.hovered || state.dragging) { hudL.style.display='none'; return; }
            
            const m = state.hovered;
            const p = m.position.clone(); p.y+=1.2; p.project(camera);
            const x = (p.x*0.5+0.5)*hudC.width, y = (p.y*-0.5+0.5)*hudC.height;
            const c = m.userData.isFolder?'#00ffff':'#ff00ff';

            hudX.beginPath(); hudX.strokeStyle=c; hudX.lineWidth=2;
            hudX.moveTo(x,y); hudX.lineTo(x+20,y-20); hudX.lineTo(x+80,y-20); hudX.stroke();
            hudX.beginPath(); hudX.fillStyle=c; hudX.arc(x,y,4,0,Math.PI*2); hudX.fill();
            hudL.style.display='block'; hudL.style.left=(x+80)+'px'; hudL.style.top=(y-35)+'px';
            hudL.innerText=m.userData.handle.name; hudL.style.color=c; hudL.style.borderColor=c;
        }

        // --- Shelf ---
        const shelf=document.getElementById('shelf');
        function moveToShelf(m) {
            filesGroup.remove(m); state.objects=state.objects.filter(o=>o!==m);
            state.shelfData.push(m.userData.handle); renderShelf(); deselect();
        }
        function renderShelf() {
            shelf.innerHTML='';
            state.shelfData.forEach((h,i)=>{
                const d=document.createElement('div'); d.className='shelf-item'; d.draggable=true;
                d.innerHTML=`<div class="shelf-img">${h.kind==='directory'?'📂':'📄'}</div><div class="shelf-text">${h.name}</div>`;
                d.ondragstart=e=>e.dataTransfer.setData('i',i);
                shelf.appendChild(d);
            });
        }
        document.getElementById('shelf-tab').onclick=function(){shelf.classList.toggle('closed'); this.innerText=shelf.classList.contains('closed')?'▲ DOCK':'▼ DOCK';};
        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();
                const v=new THREE.Vector3((e.clientX/container.clientWidth)*2-1,-(e.clientY/container.clientHeight)*2+1,0.5).unproject(camera).sub(camera.position).normalize();
                const pos=camera.position.clone().add(v.multiplyScalar(-camera.position.z/v.z));
                createMonolith(h, pos.x, pos.z);
            }
        };

        // --- Tree ---
        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=`<div class="icon-3d icon-${h.kind}"></div><div>${h.name}</div>`;
            e.onclick=()=>enterFolder(h);
            return e;
        }

        // --- Loop ---
        function animate() {
            requestAnimationFrame(animate);
            orbitControls.update();
            drawHUD();
            if(state.selected) updateReticle();
            composer.render();
        }
        animate();
    </script>
</body>
</html>

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

コメント

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