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


画像
画像

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


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Exp3D 1.5.2</title>
    <style>
        :root {
            --cyan: #00ffff;
            --magenta: #ff00ff;
            --orange: #ffaa00;
            --yellow: #ffee00;
            --ocher: #ccaa00;
            --dark-ocher: #886600;
            --blue: #0088ff;
            --bg: #000000;
            --panel: rgba(5, 10, 15, 0.95);
        }
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', monospace; user-select: none; color: #fff; }

        #app { display: flex; width: 100vw; height: 100vh; position: relative; overflow: hidden; }

        /* Panes */
        #pane-tree { 
            flex: 0 0 25%; position: relative; overflow: hidden; 
            border-right: 1px solid #333; 
            background: rgba(0,0,0,0.3); 
            z-index: 10; 
            display: flex; flex-direction: column;
            pointer-events: auto; 
        }
        #pane-file { 
            flex: 1; position: relative; overflow: hidden; 
            background: transparent; 
            z-index: 10; pointer-events: none; 
        }
        
        .pane-label, .sys-btn, .stock-item, .stock-tab-item, #stock, #stock-tabs-container, #pagination-ctrl, .tree-node, .view-switch-btn, #btn-select-root {
            pointer-events: auto;
        }

        /* Splitter */
        #splitter {
            width: 6px; cursor: col-resize; background: #111; border-left: 1px solid #444; border-right: 1px solid #444;
            display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100;
            transition: background 0.2s; pointer-events: auto;
        }
        #splitter:hover, #splitter.active { background: var(--orange); }
        .grip { width: 2px; height: 4px; background: #666; margin: 2px 0; }

        #canvas-wrap {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%; 
            z-index: 0; pointer-events: auto; 
        }
        canvas { display: block; outline: none; }

        #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
        #hud-canvas { width: 100%; height: 100%; display: block; }

        /* Tree Header */
        #tree-header {
            height: 40px; background: linear-gradient(to bottom, rgba(0,20,40,0.9), transparent);
            display: flex; align-items: center; padding: 0 10px; flex-shrink: 0;
            border-bottom: 1px solid rgba(255,255,255,0.1);
            gap: 10px;
        }
        .tree-title { font-size: 14px; font-weight: bold; color: var(--cyan); letter-spacing: 1px; white-space: nowrap; }
        
        #btn-select-root {
            background: rgba(0,0,0,0.5); border: 1px solid var(--cyan); color: var(--cyan);
            padding: 2px 8px; font-size: 11px; cursor: pointer; transition: 0.2s; white-space: nowrap; font-weight: bold;
        }
        #btn-select-root:hover { background: var(--cyan); color: #000; box-shadow: 0 0 5px var(--cyan); }

        .view-switch { display: flex; gap: 2px; }
        .view-switch-btn {
            background: rgba(0,0,0,0.5); border: 1px solid #444; color: #666;
            padding: 2px 8px; font-size: 11px; cursor: pointer; transition: 0.2s;
        }
        .view-switch-btn:hover { color: #fff; border-color: #888; }
        .view-switch-btn.active { background: var(--ocher); color: #000; border-color: var(--ocher); font-weight: bold; box-shadow: 0 0 5px var(--ocher); }

        #top-bar {
            position: absolute; top: 0; left: 0; width: 100%; height: 40px;
            display: flex; align-items: center; justify-content: flex-end; padding: 0 20px;
            z-index: 60; pointer-events: none;
        }

        .pane-label {
            position: absolute; bottom: 10px; left: 10px; font-size: 12px; 
            color: rgba(255,255,255,0.3); pointer-events: none; z-index: 10;
        }
        
        #mode-info {
            position: absolute; top: 10px; right: 20px; text-align: right; pointer-events: none;
            color: var(--yellow); text-shadow: 0 0 5px var(--yellow); font-weight: bold; font-size: 16px; z-index: 20;
        }
        #pagination-ctrl {
            position: absolute; top: 35px; right: 20px;
            display: flex; align-items: center; gap: 5px;
            z-index: 60;
        }
        .page-btn {
            background: rgba(0,0,0,0.5); border: 1px solid var(--blue); color: var(--blue);
            width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;
            cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 12px;
        }
        .page-btn:hover { background: var(--blue); color: #000; }
        .page-btn.disabled { opacity: 0.3; pointer-events: none; border-color: #555; color: #555; }
        #page-display {
            font-size: 14px; font-weight: bold; color: var(--blue); min-width: 50px; text-align: center;
        }

        /* 2D Tree */
        #tree-container { 
            padding: 10px; overflow-y: auto; flex: 1; box-sizing: border-box; 
            display: none; 
            background: rgba(0,0,0,0.3);
        }
        #tree-container::-webkit-scrollbar { width: 8px; }
        #tree-container::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
        #tree-container::-webkit-scrollbar-thumb { background: #333; border: 1px solid #444; }
        #tree-container::-webkit-scrollbar-thumb:hover { background: var(--ocher); }

        .tree-node {
            padding: 4px 8px; color: #aaa; cursor: pointer; display: flex; align-items: center;
            border-bottom: 1px solid rgba(255,255,255,0.05); transition: background 0.2s; font-size: 13px;
        }
        .tree-node:hover { background: rgba(0,255,255,0.1); color: #fff; }
        .tree-icon { margin-right: 8px; font-size: 14px; color: var(--ocher); display: inline-block; width: 16px; text-align: center; }

        #loader {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.85); display: flex; flex-direction: column;
            justify-content: center; align-items: center; z-index: 200;
            display: none; pointer-events: auto;
        }
        #loader.active { display: flex; }
        .loader-text { color: var(--cyan); font-size: 24px; margin-bottom: 20px; animation: blink 1s infinite; letter-spacing: 2px; }
        @keyframes blink { 50% { opacity: 0.5; } }

        #stock {
            position: absolute; bottom: 0; left: 0; right: 0; height: 180px;
            background: linear-gradient(to top, rgba(20,10,0,0.95), transparent);
            border-top: 2px solid var(--orange); display: flex; align-items: center; 
            padding: 0 50px; gap: 40px; 
            z-index: 40; transition: bottom 0.3s; overflow-x: hidden;
            cursor: grab; pointer-events: auto;
        }
        #stock.active { cursor: grabbing; }
        #stock.closed { bottom: -180px; }
        
        #stock-tabs-container {
            position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%);
            display: flex; gap: 5px; z-index: 41; transition: bottom 0.3s; pointer-events: auto;
        }
        #stock.closed + #stock-tabs-container { bottom: 0; }
        
        .stock-tab-item {
            background: rgba(50, 20, 0, 0.8); color: #aaa; 
            padding: 2px 20px; font-weight: bold; font-size: 12px;
            cursor: pointer; clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%);
            border-bottom: none; transition: 0.2s; min-width: 60px; text-align: center;
            border-top: 2px solid #553300; pointer-events: auto;
        }
        .stock-tab-item:hover { color: #fff; background: rgba(100, 50, 0, 0.8); }
        .stock-tab-item.active { background: var(--orange); color: #000; border-top: 2px solid #fff; }
        
        .stock-item {
            width: 100px; height: 130px; 
            background: rgba(0,0,0,0.3); border: 1px solid var(--orange);
            display: flex; flex-direction: column; align-items: center; 
            cursor: grab; color: var(--orange); flex-shrink: 0; position: relative;
            transition: 0.2s; user-select: none; pointer-events: auto;
        }
        .stock-item:hover { background: rgba(255,170,0,0.1); box-shadow: 0 0 10px rgba(255,170,0,0.2); }
        .stock-thumb { width: 90px; height: 90px; margin-top: 5px; object-fit: contain; background: #000; display:flex; justify-content:center; align-items:center; overflow:hidden; }
        .stock-label { font-size: 11px; margin-top: 5px; width: 90px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: bold; }

        .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; padding: 5px 0; overflow: hidden; pointer-events: auto;
        }
        .ctx-menu.opening { animation: menuOpen 0.2s ease-out forwards; }
        @keyframes menuOpen { 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; transition: background 0.1s; }
        .ctx-item:hover { background: var(--cyan); color: #000; }
        .ctx-menu.closing .ctx-item { animation: itemFold 0.2s forwards ease-in; }
        @keyframes itemFold { 0% { transform: translateY(0); opacity: 1; height: 40px; } 100% { transform: translateY(-20px); opacity: 0; height: 0; padding: 0; border: none; } }

        .modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8);
            display: flex; justify-content: center; align-items: center; z-index: 300;
            opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
            perspective: 1500px;
        }
        .modal-overlay.active { opacity: 1; pointer-events: auto; visibility: visible; }
        
        .modal-box { 
            border: 1px solid var(--cyan); background: #000; 
            display: flex; flex-direction: column; box-shadow: 0 0 50px rgba(0,255,255,0.2); 
            transform-style: preserve-3d; opacity: 0; transform-origin: center;
        }
        .modal-overlay.opening .modal-box { animation: animFlipOpen 0.6s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        .modal-overlay.closing .modal-box { animation: animFlipClose 0.5s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        @keyframes animFlipOpen { 0% { transform: translateZ(-500px) rotateX(180deg); opacity: 0; } 100% { transform: translateZ(0) rotateX(0deg); opacity: 1; } }
        @keyframes animFlipClose { 0% { transform: translateZ(0) rotateX(0deg); opacity: 1; } 100% { transform: translateZ(-500px) rotateX(-180deg); opacity: 0; } }

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

        #p-box { width: 90%; height: 90%; }
        .hex-view { width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; border: none; resize: none; font-family: 'Courier New', monospace; outline: none; }
        #d-box { width: 400px; height: 200px; }
        #d-body { flex-direction: column; padding: 20px; gap: 20px; }
        .d-input { width: 100%; background: #111; border: 1px solid #333; color: #fff; padding: 10px; font-family: 'Segoe UI', monospace; font-size: 16px; outline: none; text-align: center; }
        .d-input:focus { border-color: var(--cyan); }
        .d-actions { display: flex; gap: 10px; width: 100%; justify-content: center; }

        .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: 9000; 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="canvas-wrap"></div>

        <div id="pane-tree">
            <div id="tree-header">
                <span class="tree-title">EXP3D SYSTEM</span>
                <button id="btn-select-root">SELECT ROOT</button>
                <div class="view-switch">
                    <button id="btn-view-2d" class="view-switch-btn">2D</button>
                    <button id="btn-view-3d" class="view-switch-btn active">3D</button>
                </div>
            </div>
            <div id="tree-container"></div>
        </div>

        <div id="splitter">
            <div class="grip"></div><div class="grip"></div><div class="grip"></div>
        </div>

        <div id="pane-file">
            <div class="pane-label">SYSTEM // OBJECTS</div>
            
            <div id="hud"><canvas id="hud-canvas"></canvas></div>

            <div id="stock" class="closed">
                <div id="stock-hint" style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>
            </div>
            <div id="stock-tabs-container">
                <div id="stock-tab-placeholder" class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>
            </div>

            <div id="mode-info">MODE: F1 (VERTICAL)</div>
            <div id="pagination-ctrl">
                <div id="btn-prev" class="page-btn">&lt;</div>
                <div id="page-display">1 / 1</div>
                <div id="btn-next" class="page-btn">&gt;</div>
            </div>
        </div>

        <div id="top-bar"></div>
        
        <div id="loader">
            <div class="loader-text">READING DATA...</div>
            <button id="btn-cancel-load" class="p-btn" style="border-color:#ff5555; color:#ff5555;">CLOSE</button>
        </div>

        <div id="ctx-obj" class="ctx-menu">
            <div class="ctx-item" id="cm-open">Open</div>
            <div class="ctx-item" id="cm-expand" style="color:var(--yellow); display:none;">Expand (Force)</div>
            <div class="ctx-item" id="cm-collapse" style="color:var(--yellow); display:none;">Collapse</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-add-stock" style="color:var(--orange)">Add to Stock</div>
            <div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</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 id="ctx-tree-bg" class="ctx-menu">
            <div class="ctx-item" id="cm-tree-up">Up Directory</div>
            <div class="ctx-item" id="cm-tree-refresh">Refresh</div>
        </div>
        <div id="ctx-tab" class="ctx-menu">
            <div class="ctx-item" id="cm-tab-hide" style="color:var(--orange)">Hide</div>
            <div class="ctx-item" id="cm-tab-cancel" style="color:#ff5555">Cancel</div>
        </div>
    </div>

    <div id="preview" class="modal-overlay">
        <div id="p-box" class="modal-box">
            <div class="modal-head">
                <span id="p-title">FILE</span>
                <div>
                    <button id="p-save" class="p-btn p-btn-save">SAVE</button>
                    <button id="p-close" class="p-btn">CLOSE</button>
                </div>
            </div>
            <div id="p-body" class="modal-body"></div>
        </div>
    </div>

    <div id="dialog" class="modal-overlay">
        <div id="d-box" class="modal-box">
            <div class="modal-head">
                <span id="d-title">INPUT</span>
            </div>
            <div id="d-body" class="modal-body">
                <input type="text" id="d-input" class="d-input" spellcheck="false" autocomplete="off">
                <div class="d-actions">
                    <button id="d-ok" class="p-btn" style="border-color:var(--cyan); color:var(--cyan); width:100px;">OK</button>
                    <button id="d-cancel" class="p-btn" style="border-color:#ff5555; color:#ff5555; width:100px;">CANCEL</button>
                </div>
            </div>
        </div>
    </div>

    <div class="scanlines"></div>

    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

        const CONFIG = {
            colFolderIdle: 0x886600, 
            colFolderActive: 0xffff00,
            colFile: 0x0088ff, 
            treeCamOffset: { x:0, y:5, z:40 },
            maxTreeChildren: 100, 
            itemsPerPage: 20
        };

        const state = {
            splitPercent: 30, draggingSplit: false, activePane: 'none',
            treeMode: '3D',
            rootHandle: null, currentHandle: null,
            treeNodes: [], fileObjects: [], 
            currentAllEntries: [], currentPage: 1, totalPages: 1,
            activeNode: null, 
            stockList: [], activeStockIdx: -1, clipboard: null,
            viewMode: 'F1',
            targetRotX: 0, currentRotX: 0, targetRotY: 0, currentRotY: 0,
            targetPosX: 0, currentPosX: 0, targetPosY: 0, currentPosY: 0,
            targetPosZ: 0, currentPosZ: 0,
            treeScrollZ: 0, targetTreeScrollZ: 0, treeScrollX: 0, targetTreeScrollX: 0,
            mouse: new THREE.Vector2(), lastMouse: new THREE.Vector2(),
            hovered: null, selected: null, dragging: null, dragStart: new THREE.Vector2(),
            contextTarget: null, tabContextTargetIdx: -1, isLoading: false
        };

        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);}
                else if(type==='error'){o.type='sawtooth';o.frequency.setValueAtTime(100,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.3);o.start(t);o.stop(t+0.3);}
            }
        };

        const container = document.getElementById('canvas-wrap');
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.autoClear = false; 
        renderer.toneMapping = THREE.ReinhardToneMapping;
        renderer.toneMappingExposure = 1.3;
        container.appendChild(renderer.domElement);

        const sceneTree = new THREE.Scene(); sceneTree.fog = new THREE.FogExp2(0x020205, 0.008);
        const camTree = new THREE.PerspectiveCamera(60, 1, 0.1, 2000);
        const groupTree = new THREE.Group(); sceneTree.add(groupTree);
        
        const sceneFile = new THREE.Scene(); sceneFile.fog = new THREE.FogExp2(0x020205, 0.015);
        const camFile = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
        camFile.position.set(0, 0, 0.1); 
        const groupFile = new THREE.Group(); sceneFile.add(groupFile);

        // Bloom
        const composer = new EffectComposer(renderer);
        
        const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
        bloomPass.threshold = 0.1; bloomPass.strength = 0.4; bloomPass.radius = 0.5;
        composer.addPass(bloomPass);

        [sceneTree, sceneFile].forEach(s => {
            s.add(new THREE.AmbientLight(0xffffff, 0.8));
            const dl = new THREE.DirectionalLight(0xffffff, 1.5);
            dl.position.set(10, 50, 20); s.add(dl);
        });

        // Backgrounds
        const starGroup = new THREE.Group();
        const starsGeo = new THREE.BufferGeometry();
        const starPos = new Float32Array(3000 * 3); 
        for(let i=0; i<9000; i++) starPos[i] = (Math.random()-0.5)*1000;
        starsGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
        const starMat = new THREE.PointsMaterial({color:0xffffff, size:0.5, transparent:true, opacity:0.8});
        starGroup.add(new THREE.Points(starsGeo, starMat));
        
        // Dynamic Lasers
        const orbitGroup = new THREE.Group();
        const lasersV = new THREE.Group();
        const lasersH = new THREE.Group();
        
        // Vertical Lasers (Ring in XZ plane)
        for(let i=0; i<10; i++) {
             const r = 25 + Math.random() * 15;
             const yOff = (Math.random()-0.5) * 80;
             const pts = [];
             const startAng = Math.random() * Math.PI * 2;
             const length = Math.PI * 0.7; // 1/3 to 1/2 circle
             for(let j=0; j<=32; j++) {
                 const t = startAng + (j/32)*length;
                 pts.push(new THREE.Vector3(Math.cos(t)*r, yOff, Math.sin(t)*r));
             }
             const curve = new THREE.CatmullRomCurve3(pts);
             const tube = new THREE.TubeGeometry(curve, 32, 0.2, 4, false);
             const mat = new THREE.MeshBasicMaterial({ color:0x0055ff, transparent:true, opacity:0.8, blending: THREE.AdditiveBlending });
             lasersV.add(new THREE.Mesh(tube, mat));
        }
        
        // Horizontal Lasers (Ring in YZ plane for Tunnel F2)
        for(let i=0; i<10; i++) {
             const r = 25 + Math.random() * 15;
             const xOff = (Math.random()-0.5) * 80;
             const pts = [];
             const startAng = Math.random() * Math.PI * 2;
             const length = Math.PI * 0.7;
             for(let j=0; j<=32; j++) {
                 const t = startAng + (j/32)*length;
                 pts.push(new THREE.Vector3(xOff, Math.cos(t)*r, Math.sin(t)*r));
             }
             const curve = new THREE.CatmullRomCurve3(pts);
             const tube = new THREE.TubeGeometry(curve, 32, 0.2, 4, false);
             const mat = new THREE.MeshBasicMaterial({ color:0x0055ff, transparent:true, opacity:0.8, blending: THREE.AdditiveBlending });
             lasersH.add(new THREE.Mesh(tube, mat));
        }
        
        orbitGroup.add(lasersV);
        orbitGroup.add(lasersH);
        starGroup.add(orbitGroup);
        sceneFile.add(starGroup);

        const gridHelperTree = new THREE.GridHelper(500, 100, 0x004488, 0x001122);
        gridHelperTree.position.y = -20; sceneTree.add(gridHelperTree);
        const gridHelperFile = new THREE.GridHelper(500, 100, 0x00ffff, 0x111111);
        gridHelperFile.position.y = -15; gridHelperFile.visible = false; sceneFile.add(gridHelperFile);

        const reticleFile = new THREE.Group(); reticleFile.renderOrder = 10000; 
        const reticleTree = new THREE.Group(); sceneTree.add(reticleTree); reticleTree.visible = false;

        function createSegmentedRing(rIn, rOut, count, speed, dir) {
            const g = new THREE.Group();
            const angle = (Math.PI * 2) / count; const gap = 0.2; 
            for(let i=0; i<count; i++) {
                const s = i * angle + gap/2; const l = angle - gap;
                const geo = new THREE.RingGeometry(rIn, rOut, 32, 1, s, l);
                const mat = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true, opacity: 1.0, side: THREE.DoubleSide, depthTest: false });
                g.add(new THREE.Mesh(geo, mat));
            }
            g.userData = { speed: speed, dir: dir }; return g;
        }
        function buildReticle(grp) {
            grp.add(createSegmentedRing(1.4, 1.45, 3, 0.08, 1));
            grp.add(createSegmentedRing(1.55, 1.6, 4, 0.05, -1));
            grp.add(createSegmentedRing(1.7, 1.75, 6, 0.02, 1));
            grp.userData = { layers: grp.children, currentScale: 0, targetScale: 0 };
        }
        buildReticle(reticleFile); buildReticle(reticleTree);

        const hudC = document.getElementById('hud-canvas'); const hudX = hudC.getContext('2d');
        function resizeRenderer() {
            const w = window.innerWidth; const h = window.innerHeight;
            renderer.setSize(w, h); composer.setSize(w, h);
            hudC.width = w; hudC.height = h;
        }
        window.addEventListener('resize', resizeRenderer); resizeRenderer();

        const magMesh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 4.8), new THREE.MeshBasicMaterial({ map: null, transparent: true, opacity: 0, depthTest: false, side: THREE.DoubleSide }));
        magMesh.renderOrder = 9999; 
        const infoMesh = new THREE.Mesh(new THREE.PlaneGeometry(12, 12), new THREE.MeshBasicMaterial({ map: null, transparent: true, opacity: 0, depthTest: false, side: THREE.DoubleSide }));
        infoMesh.renderOrder = 9999;
        
        const infoCvs = document.createElement('canvas'); infoCvs.width=512; infoCvs.height=512;
        const infoCtx = infoCvs.getContext('2d');
        const infoTex = new THREE.CanvasTexture(infoCvs);
        infoMesh.material.map = infoTex;
        infoMesh.userData = { fullLines: [], charCount: 0, ctx: infoCtx, tex: infoTex };

        const arrowLineGeo = new THREE.BufferGeometry();
        const arrowLine = new THREE.Line(arrowLineGeo, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false, opacity: 0.8, transparent: true }));
        arrowLine.renderOrder = 9999;

        const treeC = document.getElementById('tree-container');
        treeC.addEventListener('pointerdown', e => { 
            closeAllMenus();
            e.stopPropagation(); 
        });
        treeC.addEventListener('wheel', e => e.stopPropagation());
        
        class TreeNode {
            constructor(handle, parent = null) {
                this.handle = handle; this.parent = parent; this.children = [];
                this.mesh = null; this.depth = parent ? parent.depth + 1 : 0;
                this.expanded = false;
            }
        }

        function createTreeMesh(node) {
            const grp = new THREE.Group();
            const geo = new THREE.BoxGeometry(3, 3, 3);
            const mat = new THREE.MeshPhongMaterial({ color: CONFIG.colFolderIdle, emissive: 0x000000, wireframe: false, transparent: true, opacity: 0.9 });
            const mesh = new THREE.Mesh(geo, mat); grp.add(mesh);
            const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: 0xffaa00, transparent:true, opacity:0.8 }));
            mesh.add(edges);
            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=64; const ctx = cvs.getContext('2d');
            ctx.font = 'bold 36px "Segoe UI"'; ctx.fillStyle='#fff'; ctx.textAlign='center';
            ctx.shadowBlur=4; ctx.shadowColor='#00ffff'; ctx.fillText(node.handle.name, 128, 45);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs) }));
            sprite.scale.set(6, 1.5, 1); sprite.position.y = -2.5; grp.add(sprite);
            grp.userData = { isNode: true, node: node };
            return grp;
        }

        function renderHtmlTree() {
            treeC.innerHTML = ''; if(state.treeNodes.length > 0) treeC.appendChild(mkNode(state.treeNodes[0]));
        }
        function mkNode(node) {
            const container = document.createElement('div');
            const row = document.createElement('div'); row.className = 'tree-node'; row.style.paddingLeft = (node.depth * 20 + 10) + 'px';
            const iconSpan = document.createElement('span'); iconSpan.className = 'tree-icon'; iconSpan.innerText = node.expanded ? '唐' : '刀';
            iconSpan.onclick = (e) => { e.stopPropagation(); if(node.children.length > 0 || !node.expanded) { node.expanded = !node.expanded; updateTreeLayout(); } };
            row.appendChild(iconSpan);
            const nameSpan = document.createElement('span'); nameSpan.innerText = node.handle.name; row.appendChild(nameSpan);
            row._node = node; 
            row.onclick = (e) => { 
                e.stopPropagation(); 
                closeAllMenus(); 
                selectNode(node); 
            };
            row.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); selectNode(node); state.contextTarget = node.handle; document.getElementById('cm-expand').style.display='flex'; document.getElementById('cm-collapse').style.display='flex'; document.getElementById('cm-add-stock').style.display='flex'; showMenu(document.getElementById('ctx-obj'), e.clientX, e.clientY); };
            container.appendChild(row);
            if(node.expanded) node.children.forEach(c => { container.appendChild(mkNode(c)); });
            return container;
        }

        function updateTreeLayout() {
            groupTree.children.filter(c => c.userData.isLine).forEach(c => groupTree.remove(c));
            if (!state.treeNodes[0] || !state.treeNodes[0].mesh) return;
            const traverse = (node, levelIdx, levelCount) => {
                if (!node.mesh) { node.mesh = createTreeMesh(node); groupTree.add(node.mesh); state.treeNodes.push(node); }
                const z = -(node.depth * 50); let x = 0;
                if (node.parent) {
                    const offset = (levelIdx - (levelCount-1)/2) * 20;
                    x = node.parent.mesh.position.x + offset;
                    const pts = [node.parent.mesh.position.clone(), new THREE.Vector3(x, 0, z)];
                    const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x004466 }));
                    line.userData = { isLine: true }; groupTree.add(line);
                }
                node.mesh.position.set(x, 0, z); node.mesh.visible = true; 
                if (node.expanded && node.children.length > 0) node.children.forEach((c, i) => traverse(c, i, node.children.length));
                else { const hide = (n) => { if(n.mesh) n.mesh.visible = false; n.children.forEach(hide); }; node.children.forEach(hide); }
            };
            traverse(state.treeNodes[0], 0, 1);
            renderHtmlTree();
        }

        document.getElementById('btn-view-2d').onclick = () => { state.treeMode = '2D'; document.getElementById('btn-view-2d').classList.add('active'); document.getElementById('btn-view-3d').classList.remove('active'); document.getElementById('tree-container').style.display = 'block'; sfx.play('click'); };
        document.getElementById('btn-view-3d').onclick = () => { state.treeMode = '3D'; document.getElementById('btn-view-3d').classList.add('active'); document.getElementById('btn-view-2d').classList.remove('active'); document.getElementById('tree-container').style.display = 'none'; groupTree.visible = true; updateTreeLayout(); sfx.play('click'); };

        async function selectNode(node, forceExpand = false) {
            if(!node) return;
            state.activeNode = node; state.currentHandle = node.handle;
            state.treeNodes.forEach(n => { if(n.mesh) { n.mesh.children[0].material.color.setHex(CONFIG.colFolderIdle); n.mesh.children[0].material.emissive.setHex(0x000000); } });
            if(node.mesh) { node.mesh.children[0].material.color.setHex(CONFIG.colFolderActive); node.mesh.children[0].material.emissive.setHex(0x332200); reticleTree.visible = true; reticleTree.scale.set(3.0, 3.0, 3.0); reticleTree.position.copy(node.mesh.position); }
            if(!node.expanded || forceExpand) {
                if (!forceExpand && !node.expanded) {
                    let count = 0; try { for await (const e of node.handle.values()) { if(e.kind==='directory') count++; if(count>CONFIG.maxTreeChildren) break; } } catch(e){}
                    if (count > CONFIG.maxTreeChildren) { alert(`Warning: >${CONFIG.maxTreeChildren} subfolders.`); loadFilesForPane(node.handle); if(node.mesh) { state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z; state.targetTreeScrollX = node.mesh.position.x; } sfx.play('click'); return; }
                }
                const children = []; try { for await (const e of node.handle.values()) if (e.kind === 'directory') children.push(new TreeNode(e, node)); } catch(e){}
                node.children = children; node.expanded = true; updateTreeLayout();
            }
            loadFilesForPane(node.handle);
            if(node.mesh) { state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z; state.targetTreeScrollX = node.mesh.position.x; }
            sfx.play('click');
        }

        async function reloadCurrentLevel() { if(state.activeNode) await selectNode(state.activeNode, true); }

        async function loadFilesForPane(handle) {
            state.isLoading = true; document.getElementById('loader').classList.add('active');
            state.currentAllEntries = []; reticleFile.visible = false; state.selected = null; state.hovered = null; hideOverlays();
            try { for await (const e of handle.values()) { state.currentAllEntries.push(e); if(state.currentAllEntries.length % 100 === 0) await new Promise(r=>setTimeout(r,0)); } } catch(e){}
            document.getElementById('loader').classList.remove('active'); state.isLoading = false;
            state.currentPage = 1; state.totalPages = Math.ceil(state.currentAllEntries.length / CONFIG.itemsPerPage) || 1;
            renderCurrentPage();
        }

        function renderCurrentPage() {
            document.getElementById('page-display').innerText = `${state.currentPage} / ${state.totalPages}`;
            document.getElementById('btn-prev').classList.toggle('disabled', state.currentPage <= 1);
            document.getElementById('btn-next').classList.toggle('disabled', state.currentPage >= state.totalPages);
            state.currentRotX=0; state.targetRotX=0; state.currentRotY=0; state.targetRotY=0;
            state.currentPosX=0; state.targetPosX=0; state.currentPosY=0; state.targetPosY=0; state.currentPosZ=0; state.targetPosZ=0;
            const start = (state.currentPage - 1) * CONFIG.itemsPerPage; const end = start + CONFIG.itemsPerPage;
            setMode(state.viewMode, state.currentAllEntries.slice(start, end));
        }
        document.getElementById('btn-prev').onclick = () => { if(state.currentPage > 1) { state.currentPage--; renderCurrentPage(); sfx.play('click'); } };
        document.getElementById('btn-next').onclick = () => { if(state.currentPage < state.totalPages) { state.currentPage++; renderCurrentPage(); sfx.play('click'); } };

        function createMonolith(handle, x, y, z, angle, index, mode) {
            const isF = handle.kind === 'directory'; const col = isF ? CONFIG.colFolderIdle : CONFIG.colFile;
            const geo = new THREE.BoxGeometry(2.4, 3.2, 0.1);
            const mat = new THREE.MeshPhysicalMaterial({ color: col, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
            const mesh = new THREE.Mesh(geo, mat);
            mesh.position.set(x, y, z);
            if(mode === 'F1' || mode === 'F3') mesh.lookAt(x*2, y, z*2);
            else if (mode === 'F2') mesh.lookAt(x, 0, 0);
            else if (mode === 'F4') mesh.rotation.set(0, 0, 0);
            
            const sizeStr = isF ? '<DIR>' : (Math.random()*5+1).toFixed(1)+' MB';
            mesh.userData = { handle, isFolder:isF, isFileObj:true, baseColor: col, sizeStr: sizeStr };

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

            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=256; const ctx = cvs.getContext('2d');
            const ext = handle.name.split('.').pop().toUpperCase().slice(0,4);
            ctx.font = `bold 100px "Segoe UI"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
            ctx.lineWidth = 6; ctx.strokeStyle = '#000000'; ctx.strokeText(isF?'DIR':ext, 128, 128); ctx.fillStyle = '#ffffff'; ctx.fillText(isF?'DIR':ext, 128, 128);
            const extSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs) }));
            extSprite.scale.set(2, 2, 1); extSprite.position.z = (mode === 'F4') ? 0.6 : -1.5; mesh.add(extSprite);

            const nCvs = document.createElement('canvas'); nCvs.width=512; nCvs.height=128; const nCtx = nCvs.getContext('2d');
            nCtx.fillStyle = '#ffffff'; nCtx.font = 'bold 80px "Segoe UI"'; 
            nCtx.shadowColor = 'black'; nCtx.shadowBlur = 6; nCtx.textAlign = 'center'; nCtx.fillText(handle.name, 256, 50);
            const nameSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(nCvs), transparent: true }));
            nameSprite.scale.set(4, 1, 1); nameSprite.position.set(0, -2.5, 0); mesh.add(nameSprite);

            if (!isF) {
                const n = handle.name.toLowerCase();
                const loadT = (t) => { mesh.material = new THREE.MeshBasicMaterial({map:t, color:0xffffff}); mesh.userData.thumbTex = t; };
                if(n.match(/\.(jpg|png|webp|gif)$/)) handle.getFile().then(f => new THREE.TextureLoader().load(URL.createObjectURL(f), loadT));
                else if(n.match(/\.bmp$/)) handle.getFile().then(f => createImageBitmap(f)).then(bmp => loadT(new THREE.CanvasTexture(bmp)));
                else if(n.match(/\.(mp4|webm)$/i)) handle.getFile().then(f => { const v = document.createElement('video'); v.src=URL.createObjectURL(f); v.muted=true; v.loop=true; v.play().then(()=>{ loadT(new THREE.VideoTexture(v)); }).catch(()=>{}); });
            }
            groupFile.add(mesh); state.fileObjects.push(mesh);
        }

        function setMode(mode, entries) {
            state.viewMode = mode; document.getElementById('mode-info').innerText = `MODE: ${mode}`;
            gridHelperFile.visible = false;
            state.fileObjects.forEach(o => groupFile.remove(o)); state.fileObjects = [];
            hideOverlays();
            state.targetRotX=0; state.currentRotX=0; state.targetRotY=0; state.currentRotY=0;
            state.targetPosX=0; state.currentPosX=0; state.targetPosY=0; state.currentPosY=0; state.targetPosZ=0; state.currentPosZ=0;
            if(!entries) return; 
            const count = entries.length; const minRad = 22; const radius = Math.max(minRad, count * 1.5); 
            
            if (mode === 'F1') {
                const spacing = 4.0; const circumference = 2 * Math.PI * radius;
                entries.forEach((e, i) => {
                    const ringPos = i * ((Math.PI * 2) / Math.max(20, count/2));
                    const x = radius * Math.sin(ringPos); const z = radius * Math.cos(ringPos);
                    let y = (i % 2 === 0) ? 3.5 : -3.5; 
                    createMonolith(e, x, y, z, ringPos, i, 'F1');
                });
            } else if (mode === 'F2') {
                const itemsPerRing = 12; const spacingX = 10.0; const totalRings = Math.ceil(count / itemsPerRing);
                entries.forEach((e, i) => {
                    const ring = Math.floor(i / itemsPerRing); const ang = (i % itemsPerRing) * ((Math.PI*2)/itemsPerRing);
                    const x = (ring * spacingX) - (totalRings * spacingX) / 2 - 10; // Shift back
                    createMonolith(e, x, radius * Math.sin(ang), radius * Math.cos(ang), ang, i, 'F2');
                });
                state.targetPosZ = -25; state.currentPosZ = -25;
            } else if (mode === 'F3') {
                state.targetPosY = 20; 
                entries.forEach((e, i) => { const ang = i * 0.3; createMonolith(e, radius*Math.sin(ang), -(i*2.0), radius*Math.cos(ang), ang, i, 'F3'); });
            } else if (mode === 'F4') {
                gridHelperFile.visible = true; const gridW = 5; const spX = 7.0; const spZ = 15.0; 
                // FIXED: Adjusted for lower altitude flyover view, closer to items for better visibility
                state.targetRotX = -0.15; state.currentRotX = -0.15; // Shallow angle (flyover)
                state.targetPosY = -10; state.currentPosY = -10; // Closer to items to make them bigger
                state.targetPosZ = 0; state.currentPosZ = 0;
                entries.forEach((e, i) => {
                    const col = i % gridW; const row = Math.floor(i / gridW);
                    const x = (col - gridW/2 + 0.5) * spX; const z = -row * spZ; 
                    createMonolith(e, x, 0, z, 0, i, 'F4');
                });
            }
        }

        const splitter = document.getElementById('splitter'); const paneTree = document.getElementById('pane-tree'); const stockDiv = document.getElementById('stock');
        let isStockDragging = false; let stockStartX, stockScrollLeft;
        stockDiv.addEventListener('pointerdown', e => { if(e.target.closest('.stock-item')) return; isStockDragging=true; stockDiv.classList.add('active'); stockStartX = e.pageX - stockDiv.offsetLeft; stockScrollLeft = stockDiv.scrollLeft; e.stopPropagation(); });
        splitter.addEventListener('pointerdown', e => { state.draggingSplit = true; splitter.classList.add('active'); e.preventDefault(); });
        
        window.addEventListener('pointerup', async e => {
            isStockDragging = false; stockDiv.classList.remove('active');
            if (state.draggingSplit) { state.draggingSplit = false; splitter.classList.remove('active'); return; }
            if (state.dragging) {
                if(e.clientY > window.innerHeight - 180 && state.dragging.mesh) await moveToStock(state.dragging.mesh.userData.handle);
                else if (!state.dragging.isMove && state.dragging.mesh) { if(state.dragging.mesh.userData.isFileObj) handleFileSelection(state.dragging.mesh); }
                state.dragging = null; 
            }
            document.body.style.cursor = 'default';
        });

        function handleFileSelection(mesh) {
            state.fileObjects.forEach(o => { if(o.userData.isFolder) o.material.color.setHex(CONFIG.colFolderIdle); });
            state.selected = mesh;
            if(mesh.userData.isFolder) mesh.material.color.setHex(CONFIG.colFolderActive);
            updateOverlays(mesh); sfx.play('lock');
            reticleFile.userData.targetScale = 3.5; reticleFile.scale.set(5.5, 5.5, 1);
        }

        function hideOverlays() {
            magMesh.opacity = 0; magMesh.visible = false; magMesh.removeFromParent();
            infoMesh.opacity = 0; infoMesh.visible = false; infoMesh.removeFromParent();
            arrowLine.visible = false; arrowLine.removeFromParent();
            reticleFile.visible = false; reticleFile.removeFromParent();
            reticleFile.userData.targetScale = 0;
        }

        function updateOverlays(target) {
            target.add(reticleFile); target.add(infoMesh); target.add(arrowLine);
            target.add(magMesh);

            const isF = target.userData.isFolder;
            infoMesh.userData.fullLines = [ `NAME: ${target.userData.handle.name}`, `KIND: ${target.userData.handle.kind.toUpperCase()}`, `DATE: ${new Date().toLocaleDateString()}`, `SIZE: ${target.userData.sizeStr}` ];
            infoMesh.userData.charCount = 0; infoMesh.visible = true; infoMesh.material.opacity = 1;

            if(isF) magMesh.material = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, depthTest: false });
            else magMesh.material = new THREE.MeshBasicMaterial({ map: target.userData.thumbTex || null, side: THREE.DoubleSide, depthTest: false });
            magMesh.visible = true; magMesh.material.opacity = 1;

            let Z_FRONT = 15.0; 
            let magZ = 15.0;

            if (state.viewMode === 'F2') {
                magZ = 4.0; 
                Z_FRONT = 6.0; 
            } else if (state.viewMode === 'F4') {
                magZ = 5.0; 
            } else {
                magZ = 15.0; 
            }

            reticleFile.position.set(0, 0, Z_FRONT + 0.1); reticleFile.rotation.set(0, 0, 0); reticleFile.scale.set(3.8, 3.8, 1); reticleFile.visible = true;
            magMesh.position.set(0, 0, magZ); magMesh.rotation.set(0, 0, 0); magMesh.scale.set(1.25, 1.25, 1); 

            // Auto-flip Info position logic
            const wp = new THREE.Vector3(); target.getWorldPosition(wp); wp.project(camFile);
            let xOff = 8.0; if(wp.x > 0.4) xOff = -10.0;
            let yOff = 0; if(wp.y < -0.6) yOff = 6.0; if(wp.y > 0.6) yOff = -6.0;

            infoMesh.position.set(xOff, yOff, Z_FRONT); infoMesh.rotation.set(0, 0, 0); infoMesh.scale.set(1, 1, 1); 

            const yLine = -2.8; const thRight = 3.6; let pts = [];
            if(xOff > 0) pts = [ new THREE.Vector3(thRight, 0, Z_FRONT), new THREE.Vector3(5.0, yLine+yOff, Z_FRONT), new THREE.Vector3(12.0, yLine+yOff, Z_FRONT) ];
            else pts = [ new THREE.Vector3(-thRight, 0, Z_FRONT), new THREE.Vector3(-5.0, yLine+yOff, Z_FRONT), new THREE.Vector3(-12.0, yLine+yOff, Z_FRONT) ];
            arrowLine.geometry.setFromPoints(pts); arrowLine.visible = true;
        }

        function getMouseForPane(e) {
             const w = window.innerWidth; const h = window.innerHeight; const splitX = Math.max(1, w * (state.splitPercent / 100));
             const mouse = new THREE.Vector2(); mouse.y = -(e.clientY / h) * 2 + 1;
             if(e.clientX < splitX) { mouse.x = (e.clientX / splitX) * 2 - 1; return { vec: mouse, pane: 'tree' }; } 
             else { const fw = w - splitX; mouse.x = ((e.clientX - splitX) / fw) * 2 - 1; return { vec: mouse, pane: 'file' }; }
        }

        window.addEventListener('pointermove', e => {
            if(isStockDragging) { e.preventDefault(); stockDiv.scrollLeft = stockScrollLeft - (e.pageX - stockDiv.offsetLeft)*1.5; return; }
            if (state.draggingSplit) { const pct = (e.clientX/window.innerWidth)*100; if(pct>10 && pct<90){ state.splitPercent=pct; paneTree.style.flex=`0 0 ${pct}%`; resizeRenderer(); } return; }
            
            const mData = getMouseForPane(e);
            state.activePane = mData.pane;
            state.mouse.copy(mData.vec);

            if (e.buttons === 1 && !state.dragging) {
                if(e.target.closest('#stock')) return;
                const dx = e.clientX - state.lastMouse.x; const dy = e.clientY - state.lastMouse.y;
                if(state.activePane === 'tree') { state.targetTreeScrollX -= dx*0.2; state.targetTreeScrollZ -= dy*0.5; }
                else {
                    if(state.viewMode==='F1') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.1; }
                    else if(state.viewMode==='F2') { state.targetPosX += dx*0.05; state.targetRotX -= dy*0.005; }
                    else if(state.viewMode==='F3') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.2; }
                    else if(state.viewMode==='F4') { state.targetPosX += dx*0.1; state.targetPosZ += dy*0.1; }
                }
            } else if (!state.dragging) {
                const raycaster = new THREE.Raycaster();
                if (state.activePane === 'tree') {
                    raycaster.setFromCamera(state.mouse, camTree);
                    const hits = raycaster.intersectObjects(groupTree.children, true);
                    let hitNode = null; if(hits.length>0) { let obj=hits[0].object; while(obj&&!obj.userData.isNode)obj=obj.parent; hitNode=obj; }
                    if (hitNode && state.hovered !== hitNode) { sfx.play('hover'); state.hovered = hitNode; document.body.style.cursor='pointer'; }
                    else if (!hitNode) { state.hovered = null; document.body.style.cursor='default'; }
                } else {
                    raycaster.setFromCamera(state.mouse, camFile);
                    const hits = raycaster.intersectObjects(state.fileObjects, true);
                    let hitObj = null; if(hits.length>0) { let obj=hits[0].object; while(obj&&!obj.userData.isFileObj)obj=obj.parent; hitObj=obj; }
                    if(hitObj) {
                        if (state.hovered !== hitObj) sfx.play('hover');
                        state.hovered = hitObj; document.body.style.cursor='pointer';
                        updateOverlays(hitObj);
                        if(!state.selected) { reticleFile.userData.targetScale = 3.5; reticleFile.scale.set(5.5, 5.5, 1); }
                    } else {
                        state.hovered = null; document.body.style.cursor='default';
                        if(!state.selected) hideOverlays(); else updateOverlays(state.selected); 
                    }
                }
            }
            state.lastMouse.set(e.clientX, e.clientY);
        });

        window.addEventListener('pointerdown', e => {
            sfx.init(); if(e.target.closest('.ctx-menu')||e.target.closest('.stock-item')||e.target.closest('.stock-tab-item')||e.target.closest('.page-btn')||e.target.closest('#stock')||e.target.closest('#tree-header')) return;
            closeAllMenus(); state.dragStart.set(e.clientX, e.clientY);
            const mData = getMouseForPane(e);
            if(mData.pane === 'tree') { if(state.hovered) selectNode(state.hovered.userData.node); }
            if(mData.pane === 'file') {
                if(state.hovered) state.dragging = { mesh: state.hovered, isMove: false };
                else { state.selected = null; state.fileObjects.forEach(o=>{if(o.userData.isFolder)o.material.color.setHex(CONFIG.colFolderIdle);}); hideOverlays(); }
            }
        });

        window.addEventListener('wheel', e => {
            const mData = getMouseForPane(e);
            if(mData.pane === 'tree') state.targetTreeScrollZ += e.deltaY*0.2;
            else {
                if(state.viewMode==='F1'||state.viewMode==='F3') state.targetRotY += e.deltaY*0.001;
                else if(state.viewMode==='F2') state.targetRotX += e.deltaY*0.001;
                else if(state.viewMode==='F4') state.targetPosZ -= e.deltaY*0.1;
                if(state.viewMode==='F3'||state.viewMode==='F1') state.targetPosY += e.deltaY*0.05;
            }
        });

        window.addEventListener('dblclick', e => {
             const mData = getMouseForPane(e);
             if(mData.pane === 'tree' && state.treeMode === '3D') {
                const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mData.vec, camTree);
                const hits = raycaster.intersectObjects(groupTree.children, true);
                if(hits.length > 0) { let obj = hits[0].object; while(obj && !obj.userData.isNode) obj = obj.parent; if(obj) { selectNode(obj.userData.node); sfx.play('click'); } }
            } else if(mData.pane === 'file' && state.selected && state.selected.userData.isFileObj) {
                 const h = state.selected.userData.handle;
                 if(h.kind==='directory') {
                      if(state.activeNode) { const child = state.activeNode.children.find(c => c.handle.name === h.name); if(child) selectNode(child); else selectNode(new TreeNode(h, state.activeNode)); }
                 } else openPreview(h);
             }
        });
        window.addEventListener('keydown', e => { if(e.key.startsWith('F') && ['F1','F2','F3','F4'].includes(e.key)) { e.preventDefault(); setMode(e.key, state.currentAllEntries.slice((state.currentPage-1)*CONFIG.itemsPerPage, state.currentPage*CONFIG.itemsPerPage)); } });

        function animate() {
            requestAnimationFrame(animate);

            state.treeScrollZ += (state.targetTreeScrollZ - state.treeScrollZ)*0.1; state.treeScrollX += (state.targetTreeScrollX - state.treeScrollX)*0.1;
            camTree.position.z = state.treeScrollZ; camTree.position.x = state.treeScrollX; camTree.position.y = CONFIG.treeCamOffset.y; camTree.lookAt(state.treeScrollX, 0, state.treeScrollZ - 100);

            state.currentRotY += (state.targetRotY - state.currentRotY)*0.1; state.currentPosY += (state.targetPosY - state.currentPosY)*0.1;
            state.currentRotX += (state.targetRotX - state.currentRotX)*0.1; state.currentPosX += (state.targetPosX - state.currentPosX)*0.1; state.currentPosZ += (state.targetPosZ - state.currentPosZ)*0.1;

            if(state.viewMode === 'F1' || state.viewMode === 'F3') { groupFile.rotation.y = state.currentRotY; groupFile.position.y = state.currentPosY; groupFile.rotation.x = 0; groupFile.position.x = 0; groupFile.position.z = 0; }
            else if (state.viewMode === 'F2') { groupFile.rotation.x = state.currentRotX; groupFile.position.x = state.currentPosX; groupFile.rotation.y = 0; groupFile.position.y = 0; groupFile.position.z = 0; }
            else if (state.viewMode === 'F4') { groupFile.rotation.set(state.currentRotX, 0, 0); groupFile.position.set(state.currentPosX, state.currentPosY, state.currentPosZ); }

            starGroup.rotation.y = state.currentRotY * 0.2 + Date.now()*0.00005; starGroup.position.y = state.currentPosY * 0.5;
            
            // Background lasers visibility/rotation switch
            lasersV.visible = (state.viewMode !== 'F2');
            lasersH.visible = (state.viewMode === 'F2');
            if(state.viewMode === 'F2') lasersH.rotation.x += 0.02;
            else lasersV.rotation.y += 0.02;

            [reticleFile, reticleTree].forEach(grp => { if(grp.visible) grp.userData.layers.forEach(l => l.rotation.z += l.userData.speed * l.userData.dir); });

            if(reticleFile.visible) {
                const rs = reticleFile.scale.x; const ts = reticleFile.userData.targetScale || 0; const ns = rs + (ts - rs) * 0.15;
                if(ts === 0 && ns < 0.1) reticleFile.visible = false; else reticleFile.scale.set(ns, ns, 1);
            }

            if((state.hovered || state.selected) && infoMesh.visible) {
                 magMesh.lookAt(camFile.position); infoMesh.lookAt(camFile.position);
                 if(infoMesh.userData.charCount < infoMesh.userData.fullLines.join('').length) {
                     infoMesh.userData.charCount += 2; const ctx = infoMesh.userData.ctx; ctx.clearRect(0,0,512,512); 
                     ctx.font = "bold 40px 'Courier New'"; ctx.fillStyle = "#00ffff"; ctx.shadowColor="#00ffff"; ctx.shadowBlur=5;
                     let drawnCount = 0; let y = 50;
                     for(let line of infoMesh.userData.fullLines) {
                         let subLine = ""; if(drawnCount < infoMesh.userData.charCount) subLine = line.substring(0, infoMesh.userData.charCount - drawnCount);
                         if(subLine) ctx.fillText(subLine, 10, y); drawnCount += line.length; y += 60;
                     }
                     infoMesh.userData.tex.needsUpdate = true;
                 }
            }

            const width = window.innerWidth; const height = window.innerHeight;
            const splitX = Math.max(1, width * (state.splitPercent / 100));
            
            // Dynamic Aspect Ratio Update
            camTree.aspect = splitX / height; 
            camTree.updateProjectionMatrix();
            camFile.aspect = (width - splitX) / height;
            camFile.updateProjectionMatrix();

            renderer.setRenderTarget(composer.readBuffer);
            renderer.clear();
            
            // Render Tree Pane (Left)
            renderer.setViewport(0, 0, splitX, height); 
            renderer.setScissor(0, 0, splitX, height);
            renderer.setScissorTest(true); 
            renderer.render(sceneTree, camTree);
            
            // Render File Pane (Right)
            renderer.setViewport(splitX, 0, width-splitX, height); 
            renderer.setScissor(splitX, 0, width-splitX, height);
            renderer.setScissorTest(true); 
            renderer.render(sceneFile, camFile);
            
            renderer.setScissorTest(false);
            renderer.setViewport(0, 0, width, height); 
            renderer.setRenderTarget(null);
            composer.render();
        }
        animate();

        const ctxObj = document.getElementById('ctx-obj'), ctxBg = document.getElementById('ctx-bg'), ctxTab = document.getElementById('ctx-tab'), ctxTreeBg = document.getElementById('ctx-tree-bg');
        function closeAllMenus() { document.querySelectorAll('.ctx-menu').forEach(m => { if(m.style.display !== 'none' && !m.classList.contains('closing')) { m.classList.remove('opening'); m.classList.add('closing'); setTimeout(() => { m.style.display = 'none'; m.classList.remove('closing'); }, 400); } }); }
        
        function showMenu(el, x, y) { 
            el.style.display='flex'; el.classList.remove('closing'); el.classList.add('opening'); 
            let posX = x; let posY = y;
            if(x + 200 > window.innerWidth) posX = x - 200;
            if(y + 250 > window.innerHeight) posY = y - 250;
            el.style.left=posX+'px'; el.style.top=posY+'px';
        }

        window.addEventListener('contextmenu', e => {
            e.preventDefault(); 
            if(e.target.closest('.stock-tab-item')) { const idx = e.target.closest('.stock-tab-item').dataset.idx; if (idx !== undefined) { state.tabContextTargetIdx = parseInt(idx); showMenu(ctxTab, e.clientX, e.clientY); } return; }
            const mData = getMouseForPane(e);
            
            if(mData.pane === 'tree') { 
                // FIXED: Perform Raycasting here to ensure accurate hit detection on right-click
                let targetNode = null;
                if(state.treeMode === '3D') {
                    const raycaster = new THREE.Raycaster();
                    raycaster.setFromCamera(mData.vec, camTree);
                    const hits = raycaster.intersectObjects(groupTree.children, true);
                    if(hits.length > 0) {
                        let obj = hits[0].object;
                        while(obj && !obj.userData.isNode) obj = obj.parent;
                        if(obj) targetNode = obj;
                    }
                } else if(state.hovered) { targetNode = state.hovered; }

                if(targetNode) {
                    const node = targetNode.userData.node;
                    selectNode(node);
                    
                    state.contextTarget = node.handle; 
                    
                    document.getElementById('cm-expand').style.display='flex'; 
                    document.getElementById('cm-collapse').style.display='flex'; 
                    document.getElementById('cm-add-stock').style.display = 'flex'; 
                    document.getElementById('cm-add-stock').innerText = 'Add Folder to Stock';
                    
                    showMenu(ctxObj, e.clientX, e.clientY); 
                } else { showMenu(ctxTreeBg, e.clientX, e.clientY); }
                return; 
            }
            if(mData.pane === 'file') { 
                if(state.hovered) {
                    state.contextTarget = state.hovered.userData.handle; handleFileSelection(state.hovered); 
                    document.getElementById('cm-expand').style.display='none'; document.getElementById('cm-collapse').style.display='none'; 
                    if(state.contextTarget.kind === 'directory') { document.getElementById('cm-add-stock').style.display = 'flex'; document.getElementById('cm-add-stock').innerText = 'Add Folder to Stock'; } else { document.getElementById('cm-add-stock').style.display = 'none'; }
                    showMenu(ctxObj, e.clientX, e.clientY); 
                } else { state.contextTarget = null; showMenu(ctxBg, e.clientX, e.clientY); }
            }
        });

        document.getElementById('cm-expand').onclick = () => { if(state.activePane==='tree') { const node = state.treeNodes.find(n=>n.handle===state.contextTarget); if(node) selectNode(node, true); } closeAllMenus(); };
        document.getElementById('cm-collapse').onclick = () => { if(state.activePane==='tree') { const node = state.treeNodes.find(n=>n.handle===state.contextTarget); if(node) { node.expanded=false; updateTreeLayout(); } } closeAllMenus(); };
        document.getElementById('cm-open').onclick = () => { const t = state.contextTarget; if(t) { if(t.kind==='directory') loadFilesForPane(t); else openPreview(t); } closeAllMenus(); };
        document.getElementById('cm-up').onclick = () => { if(state.activeNode && state.activeNode.parent) selectNode(state.activeNode.parent); closeAllMenus(); };
        document.getElementById('cm-refresh').onclick = () => { reloadCurrentLevel(); closeAllMenus(); };
        document.getElementById('cm-tree-up').onclick = () => { if(state.activeNode && state.activeNode.parent) selectNode(state.activeNode.parent); closeAllMenus(); };
        document.getElementById('cm-tree-refresh').onclick = () => { reloadCurrentLevel(); closeAllMenus(); };
        
        document.getElementById('cm-add-stock').onclick = async () => { 
            const t=state.contextTarget; 
            if(t&&t.kind==='directory') { 
                if(state.stockList.length === 0) {
                    try {
                        const stockDirHandle = await state.rootHandle.getDirectoryHandle('STOCK', {create: true});
                        state.stockList.push({handle: stockDirHandle, entries: []});
                        state.activeStockIdx = 0;
                        if(t.move) { await t.move(stockDirHandle); reloadCurrentLevel(); }
                    } catch(e) { console.error(e); }
                } 
                else if(state.stockList.find(s=>s.handle.name===t.name)) { state.activeStockIdx=state.stockList.findIndex(s=>s.handle.name===t.name); } 
                else { state.stockList.push({handle:t, entries:[]}); state.activeStockIdx=state.stockList.length-1; } 
                renderStockTabs(); refreshStock(); document.getElementById('stock').classList.remove('closed'); 
            } 
            closeAllMenus(); 
        };

        document.getElementById('cm-tab-hide').onclick=()=>{ document.getElementById('stock').classList.add('closed'); closeAllMenus(); };
        document.getElementById('cm-tab-cancel').onclick=()=>{ state.stockList.splice(state.tabContextTargetIdx, 1); if(state.stockList.length===0){state.activeStockIdx=-1;document.getElementById('stock').classList.add('closed');} else state.activeStockIdx=Math.max(0, state.stockList.length-1); renderStockTabs(); refreshStock(); closeAllMenus(); };
        document.getElementById('cm-new').onclick = async () => { closeAllMenus(); const n = await customPrompt("NEW FOLDER", "New Folder"); if(n) { await state.currentHandle.getDirectoryHandle(n, {create:true}); reloadCurrentLevel(); } };
        document.getElementById('cm-rename').onclick = async () => { closeAllMenus(); const t = state.contextTarget; if(t) { const n = await customPrompt("RENAME", t.name); if(n && n!==t.name && t.move) { await t.move(n); reloadCurrentLevel(); } } };
        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(); reloadCurrentLevel(); } closeAllMenus(); };
        document.getElementById('cm-copy').onclick = () => { state.clipboard = state.contextTarget; closeAllMenus(); };
        document.getElementById('cm-delete').onclick = async () => { closeAllMenus(); const t = state.contextTarget; if(t && confirm('Delete?')) { await state.currentHandle.removeEntry(t.name, {recursive:true}); reloadCurrentLevel(); } };

        function renderStockTabs() { const c = document.getElementById('stock-tabs-container'); c.innerHTML = ''; if(state.stockList.length === 0) { c.innerHTML = '<div class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>'; return; } state.stockList.forEach((item, i) => { const div = document.createElement('div'); div.className = 'stock-tab-item'; if(i === state.activeStockIdx) div.classList.add('active'); div.innerText = item.handle.name.toUpperCase(); div.dataset.idx = i; div.onclick = () => { state.activeStockIdx = i; document.getElementById('stock').classList.remove('closed'); renderStockTabs(); refreshStock(); sfx.play('click'); }; c.appendChild(div); }); }
        async function refreshStock() { const s = document.getElementById('stock'); s.innerHTML=''; if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>'; return; } const currentStock = state.stockList[state.activeStockIdx]; currentStock.entries = []; try { for await (const entry of currentStock.handle.values()) currentStock.entries.push(entry); if(currentStock.entries.length === 0) s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY FOLDER ]</div>'; else { for (let i=0; i<currentStock.entries.length; i++) { const h = currentStock.entries[i]; const d = document.createElement('div'); d.className = 'stock-item'; d.draggable = true; const thumb = document.createElement('div'); thumb.className = 'stock-thumb'; if(h.kind === 'directory') thumb.innerHTML = '<span style="font-size:40px">唐</span>'; else { const n = h.name.toLowerCase(); if(n.match(/\.(jpg|png|webp|gif)$/)) h.getFile().then(f => { const img=document.createElement('img'); img.src=URL.createObjectURL(f); img.style.maxWidth='100%'; img.style.maxHeight='100%'; thumb.appendChild(img); }); else thumb.innerHTML = '<span style="font-size:30px">塘</span>'; } d.appendChild(thumb); const lbl = document.createElement('div'); lbl.className = 'stock-label'; lbl.innerText = h.name; d.appendChild(lbl); d.ondragstart = e => { e.dataTransfer.setData('stockIndex', i); e.stopPropagation(); }; s.appendChild(d); } } } catch(e) { s.innerHTML = '<div style="color:red">ACCESS ERROR</div>'; } }
        async function moveToStock(handle) { if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { alert("No Active Stock Tab!"); return; } const dest = state.stockList[state.activeStockIdx].handle; if(handle.move) { try { await handle.move(dest); refreshStock(); reloadCurrentLevel(); } catch(e){ sfx.play('error'); alert(e.message); } } }
        async function moveFromStock(idx) { if(state.activeStockIdx<0) return; const h = state.stockList[state.activeStockIdx].entries[idx]; if(h && h.move) { try { await h.move(state.currentHandle); refreshStock(); reloadCurrentLevel(); } catch(e){ sfx.play('error'); alert(e.message); } } }
        container.ondragover = e => e.preventDefault(); container.ondrop = async e => { e.preventDefault(); const idx = e.dataTransfer.getData('stockIndex'); if(idx) moveFromStock(parseInt(idx)); };

        const dModal=document.getElementById('dialog'), dInput=document.getElementById('d-input'); let dResolve=null;
        function customPrompt(title, val) { return new Promise(r => { document.getElementById('d-title').innerText=title; dInput.value=val; dResolve=r; dModal.classList.remove('closing'); dModal.classList.add('active','opening'); dInput.focus(); sfx.play('open'); }); }
        function closeDialog(res){ dModal.classList.remove('opening'); dModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{dModal.classList.remove('active','closing'); if(dResolve) dResolve(res); dResolve=null;},500); }
        document.getElementById('d-ok').onclick=()=>closeDialog(dInput.value); document.getElementById('d-cancel').onclick=()=>closeDialog(null); dInput.onkeydown=e=>{if(e.key==='Enter')closeDialog(dInput.value);if(e.key==='Escape')closeDialog(null);};

        const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body'); let curPH=null;
        async function openPreview(h) { curPH=h; pModal.classList.remove('closing'); pModal.classList.add('active','opening'); sfx.play('open'); document.getElementById('p-title').innerText=h.name; pBody.innerHTML='Loading...'; try { const f = await h.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 { const t = await f.text(); pBody.innerHTML=`<textarea id="editor-area" class="hex-view">${t}</textarea>`; document.getElementById('p-save').style.display='block'; } } catch(e){ pBody.innerText="Error"; } }
        document.getElementById('p-close').onclick=()=>{ pModal.classList.remove('opening'); pModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{pModal.classList.remove('active','closing');},500); };
        document.getElementById('p-save').onclick=async()=>{ const ta=document.getElementById('editor-area'); if(curPH && ta) { try { const w=await curPH.createWritable(); await w.write(ta.value); await w.close(); alert("Saved"); } catch(e){alert("Error");} } };

        document.getElementById('btn-select-root').onclick = async () => { sfx.init(); sfx.play('click'); try { const h = await window.showDirectoryPicker(); state.rootHandle = h; state.treeNodes = []; state.activeNode = null; groupTree.children.slice().forEach(c => { if(c.userData.isNode || c.userData.isLine) groupTree.remove(c); }); const root = new TreeNode(h); root.mesh = createTreeMesh(root); groupTree.add(root.mesh); state.treeNodes.push(root); await selectNode(root); updateTreeLayout(); } catch(e) { console.error(e); } };
    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
SF映画っぽいエクスプローラ「Exp3D 1.5」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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