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


画像
画像

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


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Exp3D 1.4.2</title>
    <style>
        :root {
            --cyan: #00ffff;
            --magenta: #ff00ff;
            --yellow: #ffee00;
            --bg: #010103;
            --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; }

        /* UI Layout */
        #app { display: flex; width: 100vw; height: 100vh; position: relative; }

        /* Sidebar */
        #sidebar {
            position: absolute; top: 0; left: 0; bottom: 0; width: 280px;
            background: var(--panel); border-right: 1px solid var(--cyan);
            display: flex; flex-direction: column; z-index: 50; backdrop-filter: blur(10px);
            transition: transform 0.3s; transform: translateX(0);
        }
        #sidebar.closed { transform: translateX(-280px); }
        #sb-tab {
            position: absolute; top: 50%; right: -30px; width: 30px; height: 100px; margin-top: -50px;
            background: var(--panel); border: 1px solid var(--cyan); border-left: none;
            display: flex; align-items: center; justify-content: center; cursor: pointer;
            writing-mode: vertical-rl; color: var(--cyan); font-weight:bold;
            border-radius: 0 10px 10px 0;
        }
        #sb-header { padding: 15px; color: var(--cyan); border-bottom: 1px solid rgba(0,255,255,0.3); font-weight: bold; letter-spacing: 2px; }
        #tree-container { flex: 1; overflow-y: auto; padding: 10px; }
        .tree-node { display: flex; align-items: center; padding: 6px; cursor: pointer; color: #888; transition:0.2s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 14px; }
        .tree-node:hover { color: #fff; background: rgba(0,255,255,0.1); }
        .icon-tree { margin-right: 8px; display: inline-block; }

        /* Main View */
        #main { flex: 1; position: relative; overflow: hidden; }
        #canvas-wrap { width: 100%; height: 100%; position: absolute; }
        #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        #hud-canvas { width: 100%; height: 100%; display: block; }
        
        #mode-info {
            position: absolute; top: 20px; right: 20px; text-align: right; pointer-events: none;
            color: var(--yellow); text-shadow: 0 0 5px var(--yellow); font-weight: bold; font-size: 18px; z-index: 20;
        }

        /* Async Loader */
        #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: 100;
            display: none;
        }
        #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; } }

        /* Shelf (Dock) */
        #shelf {
            position: absolute; bottom: 0; left: 0; right: 0; height: 160px;
            background: linear-gradient(to top, rgba(0,20,30,0.98), transparent);
            border-top: 2px solid var(--magenta); display: flex; align-items: center; padding: 0 50px; gap: 20px;
            z-index: 40; transition: bottom 0.3s; overflow-x: auto;
        }
        #shelf.closed { bottom: -160px; }
        #shelf-tab {
            position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%);
            background: var(--magenta); color: #000; padding: 2px 30px; font-weight: bold;
            cursor: pointer; clip-path: polygon(15% 0, 85% 0, 100% 100%, 0% 100%); z-index: 41; transition: bottom 0.3s;
        }
        #shelf.closed + #shelf-tab { bottom: 0; }
        .shelf-item {
            width: 100px; height: 120px; background: rgba(0,0,0,0.6); border: 1px solid var(--magenta);
            display: flex; flex-direction: column; align-items: center; justify-content: center;
            cursor: grab; color: var(--magenta); flex-shrink: 0; position: relative;
        }

        /* Context Menu */
        .ctx-menu {
            position: fixed; display: none; flex-direction: column;
            background: rgba(10, 15, 20, 0.98); border: 1px solid var(--cyan);
            box-shadow: 0 0 20px rgba(0,255,255,0.3); min-width: 200px; z-index: 9999;
            transform-origin: top; animation: menuSlide 0.15s ease-out; padding: 5px 0;
        }
        @keyframes menuSlide { from { transform: scaleY(0); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
        .ctx-item { padding: 10px 15px; cursor: pointer; color: #ccc; display: flex; justify-content: space-between; border-bottom: 1px solid #333; }
        .ctx-item:hover { background: var(--cyan); color: #000; }
        .ctx-item:last-child { border-bottom: none; }

        /* Preview (Backflip) */
        #preview {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9);
            display: flex; justify-content: center; align-items: center; z-index: 300;
            opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
            perspective: 1500px; /* Perspective for 3D rotation */
        }
        #preview.active { opacity: 1; pointer-events: auto; visibility: visible; }
        #p-box { 
            width: 90%; height: 90%; border: 1px solid var(--cyan); background: #000; 
            display: flex; flex-direction: column; box-shadow: 0 0 50px rgba(0,255,255,0.2); 
            
            /* Initial State: Tiny and Rotated 3 times (1080deg) */
            transform: scale(0.0) rotateX(1080deg); 
            
            /* Slow down transition to see the flips */
            transition: transform 1.0s cubic-bezier(0.175, 0.885, 0.32, 1.275); 
        }
        #preview.active #p-box { 
            /* Final State: Normal size, no rotation */
            transform: scale(1) rotateX(0deg); 
        }
        
        #p-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; align-items: center; }
        #p-body { flex: 1; padding: 0; overflow: hidden; position: relative; background: #050505; display: flex; justify-content: center; align-items: center; }
        .p-btn { background: transparent; border: 1px solid var(--cyan); color: var(--cyan); padding: 5px 15px; margin-left: 10px; cursor: pointer; transition: 0.2s; }
        .p-btn:hover { background: var(--cyan); color: #000; }
        .p-btn-save { display: none; border-color: #0f0; color: #0f0; }
        .hex-view { width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; border: none; resize: none; font-family: 'Courier New', monospace; }

        .scanlines {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;
            background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
            background-size: 100% 3px; z-index: 9999; opacity: 0.15;
        }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>
    <div id="app">
        <div id="sidebar">
            <div id="sb-header">SYSTEM // NAV</div>
            <div style="padding:10px;"><button id="btn-mount" class="p-btn" style="width:100%">MOUNT DRIVE</button></div>
            <div id="tree-container"></div>
            <div id="sb-tab">TREE</div>
        </div>

        <div id="main">
            <div id="canvas-wrap"></div>
            <div id="hud"><canvas id="hud-canvas"></canvas></div>
            <div id="mode-info">MODE: F1 (VERTICAL)</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="shelf">
                <div style="color:var(--magenta); opacity:0.6;">[ DRAG FILES HERE ]</div>
            </div>
            <div id="shelf-tab">DOCK</div>

            <div id="ctx-obj" class="ctx-menu">
                <div class="ctx-item" id="cm-open">Open</div>
                <div class="ctx-item" id="cm-copy">Copy</div>
                <div class="ctx-item" id="cm-rename">Rename</div>
                <div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</div>
                <div class="ctx-item" id="cm-prop">Properties</div>
            </div>
            <div id="ctx-bg" class="ctx-menu">
                <div class="ctx-item" id="cm-new">New Folder</div>
                <div class="ctx-item" id="cm-paste">Paste</div>
                <div class="ctx-item" id="cm-up">Up Directory</div>
                <div class="ctx-item" id="cm-refresh">Refresh</div>
            </div>
        </div>
    </div>

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

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

        const CONFIG = {
            colFolder: 0xffee00, colFile: 0x00ffff, colCursor: 0xffee00, grid: 0x002233
        };
        const state = {
            rootHandle: null, currentHandle: null, currentPath: [],
            objects: [], shelfData: [], dragging: null, hovered: null, selected: null,
            clipboard: null, currentEntries: [],
            viewMode: 'F1', 
            targetRotX: 0, currentRotX: 0,
            targetRotY: 0, currentRotY: 0,
            targetPosX: 0, currentPosX: 0,
            targetPosY: 0, currentPosY: 0,
            isRotating: false, lastMouse: new THREE.Vector2(),
            isTransitioning: false, contextTarget: null,
            dragStart: new THREE.Vector2(), isLoading: false
        };

        // --- Sound ---
        const sfx = {
            ctx: null,
            init: () => { if (!sfx.ctx) sfx.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
            play: (type) => {
                if (!sfx.ctx) return;
                const t = sfx.ctx.currentTime;
                const o = sfx.ctx.createOscillator(); const g = sfx.ctx.createGain();
                o.connect(g); g.connect(sfx.ctx.destination);
                if(type==='click'){o.frequency.setValueAtTime(600,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
                else if(type==='hover'){o.frequency.setValueAtTime(300,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.05);o.start(t);o.stop(t+0.05);}
                else if(type==='open'){o.type='triangle';o.frequency.setValueAtTime(200,t);o.frequency.linearRampToValueAtTime(600,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
                else if(type==='back'){o.type='triangle';o.frequency.setValueAtTime(600,t);o.frequency.linearRampToValueAtTime(200,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
                else if(type==='lock'){o.type='square';o.frequency.setValueAtTime(1000,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
            }
        };

        // --- Three.js ---
        const container = document.getElementById('canvas-wrap');
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x020205, 0.01);

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

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setSize(container.clientWidth, container.clientHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        container.appendChild(renderer.domElement);

        scene.add(new THREE.AmbientLight(0xffffff, 1.5));
        const dl = new THREE.DirectionalLight(0xffffff, 2);
        dl.position.set(0, 50, 0);
        scene.add(dl);
        
        // --- Celestial Background ---
        const starGroup = new THREE.Group();
        const starsGeo = new THREE.BufferGeometry();
        const starCount = 5000;
        const starPos = new Float32Array(starCount * 3);
        for(let i=0; i<starCount*3; i++) starPos[i] = (Math.random()-0.5)*500;
        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));

        const orbitGroup = new THREE.Group();
        for(let i=0; i<8; i++) {
            const r = 40 + i*15;
            const geo = new THREE.TorusGeometry(r, 0.1, 16, 100);
            const mat = new THREE.MeshBasicMaterial({color:0x00ffff, side:THREE.DoubleSide, transparent:true, opacity:0.15});
            const ring = new THREE.Mesh(geo, mat);
            ring.rotation.x = Math.random()*Math.PI; ring.rotation.y = Math.random()*Math.PI;
            orbitGroup.add(ring);
        }
        starGroup.add(orbitGroup);
        scene.add(starGroup);

        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(scene, camera));
        const bloom = new UnrealBloomPass(new THREE.Vector2(container.clientWidth, container.clientHeight), 1.5, 0.4, 0.85);
        composer.addPass(bloom);

        const orbitControls = new OrbitControls(camera, renderer.domElement);
        orbitControls.enableDamping = true;
        orbitControls.enablePan = false; orbitControls.enableRotate = false; orbitControls.enableZoom = false; 
        orbitControls.target.set(0,0,-1);

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

        // --- Reticle (Modified: Baumkuchen/Roulette Style) ---
        const reticleGroup = new THREE.Group();
        scene.add(reticleGroup);
        reticleGroup.visible = false;

        // Layer 1: Inner (Solid, fast spin)
        const r1 = new THREE.Mesh(new THREE.RingGeometry(1.4, 1.6, 32), new THREE.MeshBasicMaterial({ color: CONFIG.colCursor, transparent: true, opacity: 0.8, side: THREE.DoubleSide }));
        reticleGroup.add(r1);

        // Layer 2: Middle (Wire/Dashed look, medium spin reverse)
        const r2 = new THREE.Mesh(new THREE.RingGeometry(1.7, 1.9, 16), new THREE.MeshBasicMaterial({ color: CONFIG.colCursor, transparent: true, opacity: 0.5, side: THREE.DoubleSide, wireframe: true }));
        reticleGroup.add(r2);

        // Layer 3: Outer (Brackets/Segments, slow spin)
        const bGeo = new THREE.BufferGeometry();
        const bs=2.2, bl=0.8; 
        // Create broken ring segments
        const segments = [];
        for(let i=0; i<8; i++) {
            const ang = (i/8)*Math.PI*2;
            const x = Math.cos(ang)*2.2; const y = Math.sin(ang)*2.2;
            segments.push(x, y, 0);
            segments.push(x*1.1, y*1.1, 0);
        }
        // Using existing bracket logic but modified for rotation
        const r3Geo = new THREE.RingGeometry(2.1, 2.2, 8, 1, 0, Math.PI*2);
        const r3 = new THREE.Mesh(r3Geo, new THREE.MeshBasicMaterial({ color: CONFIG.colCursor, transparent: true, opacity: 0.3, side: THREE.DoubleSide, wireframe:false }));
        // Create gaps in the ring geometry manually or just use a dashed texture. 
        // For simplicity and "tech" look, let's use a wireframe ring with low segments.
        reticleGroup.add(r3);
        
        // Store references for independent rotation
        reticleGroup.userData = { r1, r2, r3 };
        
        // --- HUD ---
        const hudC = document.getElementById('hud-canvas');
        const hudX = hudC.getContext('2d');
        function resizeHUD() {
            const dpr = window.devicePixelRatio || 1;
            hudC.width = container.clientWidth * dpr; hudC.height = container.clientHeight * dpr;
            hudX.scale(dpr, dpr);
        }
        window.onresize = () => {
            camera.aspect=container.clientWidth/container.clientHeight; camera.updateProjectionMatrix();
            renderer.setSize(container.clientWidth, container.clientHeight);
            composer.setSize(container.clientWidth, container.clientHeight);
            resizeHUD();
        }; 
        resizeHUD();

        // --- Logic ---
        document.getElementById('btn-mount').onclick = async () => {
            sfx.init(); sfx.play('click');
            try {
                const h = await window.showDirectoryPicker();
                state.rootHandle = h;
                enterFolder(h, false); 
                updateTree(h);
            } catch(e) {}
        };

        window.addEventListener('keydown', e => {
            if(e.key === 'F1') { e.preventDefault(); setMode('F1'); }
            if(e.key === 'F2') { e.preventDefault(); setMode('F2'); }
        });

        function setMode(mode) {
            state.viewMode = mode;
            document.getElementById('mode-info').innerText = mode==='F1' ? 'MODE: F1 (VERTICAL)' : 'MODE: F2 (HORIZONTAL)';
            sfx.play('click');
            rebuildCylinder();
        }

        // --- Async Loader ---
        document.getElementById('btn-cancel-load').onclick = () => {
            state.isLoading = false; 
            document.getElementById('loader').classList.remove('active');
        };

        async function enterFolder(handle, animate=true) {
            state.currentHandle = handle;
            if(!state.currentPath.includes(handle)) state.currentPath.push(handle);
            
            state.isLoading = true;
            document.getElementById('loader').classList.add('active');
            
            // Clean up old
            state.objects.forEach(o => filesGroup.remove(o));
            state.objects = [];
            reticleGroup.visible = false; state.selected = null; updatePopup(null);
            
            const entries = [];
            try {
                for await (const e of handle.values()) {
                    if (!state.isLoading) break;
                    entries.push(e);
                    if (entries.length % 20 === 0) await new Promise(r => setTimeout(r, 0));
                }
            } catch(e) {}

            document.getElementById('loader').classList.remove('active');
            state.isLoading = false;
            state.currentEntries = entries;
            
            if(animate) animateTransition('in', rebuildCylinder);
            else rebuildCylinder();
        }

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

        // --- Transitions (Modified to Match 1.3 Logic) ---
        function animateTransition(dir, cb) {
            if (state.isTransitioning) return;
            state.isTransitioning = true;
            sfx.play(dir==='in'?'open':'back');
            
            const startT = Date.now();
            const startS = filesGroup.scale.x;
            
            // 1.3 Style: "Zoom In" scales UP to 3.0+, "Zoom Out" scales DOWN to 0.1
            const targetS = dir==='in' ? 4.0 : 0.01; 

            function loop1() {
                const p = Math.min((Date.now()-startT)/400, 1);
                // Quadratic Ease In
                const s = startS + (targetS - startS) * (p*p);
                
                // Apply scale to the whole group (Simulates flying through/away)
                filesGroup.scale.set(s,s,s);
                
                // Fade out content
                state.objects.forEach(o => { if(o.material) o.material.opacity = (1-p)*0.5; });

                if(p < 1) requestAnimationFrame(loop1);
                else {
                    // Phase 2: Switch Content
                    cb(); 

                    // Reset transforms
                    filesGroup.rotation.set(0,0,0);
                    state.targetRotX=0; state.currentRotX=0; state.targetRotY=0; state.currentRotY=0;
                    state.targetPosX=0; state.currentPosX=0; state.targetPosY=0; state.currentPosY=0;

                    // Setup for arrival
                    // If we came IN, new content starts small (0.1) and grows to 1
                    // If we went OUT, new content starts huge (4.0) and shrinks to 1
                    const startS2 = dir==='in' ? 0.1 : 4.0;
                    filesGroup.scale.set(startS2, startS2, startS2);

                    const startT2 = Date.now();
                    function loop2() {
                        const p2 = Math.min((Date.now()-startT2)/500, 1);
                        // Ease Out Back for a nice "pop" effect
                        const ease = 1 - Math.pow(1-p2, 3); 
                        const s2 = startS2 + (1.0 - startS2) * ease;

                        filesGroup.scale.set(s2,s2,s2);
                        
                        // Fade in new content
                        state.objects.forEach(o => {
                             if(o.material) o.material.opacity = p2 * 0.5; // Final opacity is roughly 0.5 for transparent boxes
                        });

                        if(p2<1) requestAnimationFrame(loop2);
                        else {
                            state.isTransitioning = false;
                            filesGroup.scale.set(1,1,1);
                        }
                    }
                    loop2();
                }
            }
            loop1();
        }

        // --- Layout Rebuild ---
        function rebuildCylinder() {
            state.objects.forEach(o => filesGroup.remove(o));
            state.objects = [];
            const entries = state.currentEntries || [];
            const count = entries.length;
            const radius = 22; 

            if(state.viewMode === 'F1') {
                // Vertical Cylinder (F1)
                const spacing = 5.0; 
                const circumference = 2 * Math.PI * radius;
                const maxCols = Math.floor(circumference / spacing);
                const cols = Math.max(1, Math.min(count, maxCols)); 
                const rowHeight = 7.0; 

                entries.forEach((e, i) => {
                    const col = i % cols;
                    const row = Math.floor(i / cols);
                    const angleStep = (Math.PI * 2) / Math.max(cols, 10);
                    const angle = col * angleStep + Math.PI; 
                    const x = radius * Math.sin(angle);
                    const z = radius * Math.cos(angle);
                    let y = -(row * rowHeight) + (Math.floor(count/cols)*rowHeight)/2;
                    if (col % 2 !== 0) y -= rowHeight * 0.5;
                    createMonolith(e, x, y, z, angle, i, 'F1');
                });
            } else {
                // Horizontal Cylinder (F2) - Wide Row
                const spacing = 16.0;
                const circumference = 2 * Math.PI * radius;
                const maxRows = Math.floor(circumference / spacing); 
                const rows = Math.max(1, Math.min(count, maxRows));
                const colWidth = 16.0; 

                entries.forEach((e, i) => {
                    const ringIndex = i % rows;
                    const colIndex = Math.floor(i / rows);
                    const angleStep = (Math.PI * 2) / Math.max(rows, 10);
                    const angle = ringIndex * angleStep;
                    const y = radius * Math.sin(angle);
                    const z = radius * Math.cos(angle);
                    const x = (colIndex * colWidth) - (Math.floor(count/rows)*colWidth)/2;
                    createMonolith(e, x, y, z, angle, i, 'F2');
                });
            }
            filesGroup.scale.set(1,1,1); filesGroup.rotation.set(0,0,0);
        }

        // Draw Helper
        function drawStrokeText(ctx, txt, x, y, size, align='left') {
            ctx.font = `bold ${size}px "Segoe UI"`;
            ctx.textAlign = align; ctx.textBaseline = 'middle';
            ctx.lineWidth = 6; ctx.strokeStyle = '#000000'; ctx.strokeText(txt, x, y);
            ctx.fillStyle = '#ffffff'; ctx.fillText(txt, x, y);
        }

        function createMonolith(handle, x, y, z, angle, index, mode) {
            const isF = handle.kind === 'directory';
            const col = isF ? CONFIG.colFolder : CONFIG.colFile;
            
            const geo = new THREE.BoxGeometry(2.4, 3.2, 0.1);
            const mat = new THREE.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') {
                mesh.lookAt(0, y, 0); 
            } else {
                mesh.lookAt(x, 0, 0); 
                mesh.rotateZ(-Math.PI/2); // Fixed F2 orientation
            }

            mesh.userData = { handle, isFolder:isF, isRoot:true, thumbTex:null };

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

            // 1. Thumbnail
            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);
            drawStrokeText(ctx, isF ? 'DIR' : ext, 128, 128, 80, 'center');
            
            const defTex = new THREE.CanvasTexture(cvs);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: defTex }));
            sprite.scale.set(2, 2, 1);
            sprite.position.z = 0.2; 
            mesh.add(sprite);

            // Async Thumb
            if (!isF) {
                const n = handle.name.toLowerCase();
                const loadT = (t) => {
                    // Fix F1 Orientation if needed
                    if(mode==='F1') { t.center.set(0.5, 0.5); t.rotation = 0; t.flipY = true; } 
                    mesh.material = new THREE.MeshBasicMaterial({map:t, color:0xffffff});
                    sprite.visible=false; 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)$/)) {
                    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(()=>{});
                    });
                }
            }

            // 2. Info Panel
            const iCvs = document.createElement('canvas'); iCvs.width=512; iCvs.height=256;
            const iCtx = iCvs.getContext('2d');
            
            const ix = mode==='F2' ? 200 : 10;
            const name = handle.name.length > 15 ? handle.name.substring(0,12)+"..." : handle.name;
            const date = new Date().toLocaleDateString();
            
            if(mode === 'F2') {
                // F2: Left Number, Right Info
                drawStrokeText(iCtx, `#${index+1}`, 80, 128, 60, 'center');
                drawStrokeText(iCtx, name, 250, 50, 40);
                drawStrokeText(iCtx, "SIZE: --", 250, 100, 30);
                drawStrokeText(iCtx, "DATE: "+date, 250, 150, 30);
            } else {
                // F1: Info Only
                drawStrokeText(iCtx, name, 10, 50, 40);
                drawStrokeText(iCtx, "SIZE: --", 10, 100, 30);
                drawStrokeText(iCtx, "DATE: "+date, 10, 150, 30);
            }

            const iTex = new THREE.CanvasTexture(iCvs);
            const iSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: iTex }));
            
            if(mode === 'F1') {
                iSprite.position.set(4.5, 0, 0); 
                iSprite.scale.set(5, 2.5, 1);
            } else {
                iSprite.position.set(0, 4.0, 0); // Above/Right in local
                iSprite.scale.set(5, 2.5, 1);
            }
            // Push text slightly forward
            iSprite.position.z += 0.2;
            mesh.add(iSprite);

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

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

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

        function updatePopup(target) {
            if(popupMesh) { filesGroup.remove(popupMesh); popupMesh=null; }
            if(!target) return;
            const geo = new THREE.PlaneGeometry(6, 8); 
            const mat = new THREE.MeshBasicMaterial({
                map: target.userData.thumbTex || null,
                color: target.userData.thumbTex ? 0xffffff : (target.userData.isFolder?CONFIG.colFolder:CONFIG.colFile),
                transparent: true, opacity: 0.95, side: THREE.DoubleSide
            });
            popupMesh = new THREE.Mesh(geo, mat);
            popupMesh.position.copy(target.position);
            
            if(state.viewMode === 'F1') {
                const dir = new THREE.Vector3().subVectors(new THREE.Vector3(0,target.position.y,0), target.position).normalize();
                popupMesh.position.add(dir.multiplyScalar(5)); 
                popupMesh.lookAt(0, target.position.y, 0); 
            } else {
                const center = new THREE.Vector3(target.position.x, 0, 0);
                const dir = new THREE.Vector3().subVectors(center, target.position).normalize();
                popupMesh.position.add(dir.multiplyScalar(5));
                popupMesh.lookAt(center);
                popupMesh.rotateZ(-Math.PI/2);
            }
            popupMesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({color:0xffffff})));
            filesGroup.add(popupMesh);
            
            reticleGroup.scale.set(3,3,1);
        }

        window.addEventListener('pointerdown', e => {
            sfx.init();
            if(e.button!==0 || !e.target.closest('canvas')) return;
            updateMouse(e);
            state.dragStart.set(e.clientX, e.clientY);
            
            raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
            
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                state.selected = t;
                state.dragging = { mesh:t, isMove: false }; 
                orbitControls.enabled = false;
            } else {
                state.selected = null;
                reticleGroup.visible = false;
                updatePopup(null);
                state.isRotating = true;
                state.lastMouse.set(e.clientX, e.clientY);
                document.body.style.cursor = 'grabbing';
            }
        });

        window.addEventListener('pointermove', e => {
            updateMouse(e);
            if(state.isRotating) {
                const deltaX = e.clientX - state.lastMouse.x;
                const deltaY = e.clientY - state.lastMouse.y;
                if(state.viewMode === 'F1') {
                    state.targetRotY -= deltaX * 0.005;
                    state.targetPosY += deltaY * 0.05; 
                } else {
                    state.targetPosX += deltaX * 0.05; 
                    state.targetRotX -= deltaY * 0.005; 
                }
                state.lastMouse.set(e.clientX, e.clientY);
            } else if(state.dragging) {
                if(state.dragStart.distanceTo(new THREE.Vector2(e.clientX, e.clientY)) > 5) state.dragging.isMove = true;
            } else {
                checkHover();
            }
        });

        window.addEventListener('pointerup', (e) => {
            if(state.dragging) {
                if(!state.dragging.isMove) {
                    activateReticle(state.dragging.mesh);
                    updatePopup(state.dragging.mesh);
                    sfx.play('lock');
                } else {
                    const m = state.dragging.mesh;
                    if(e.clientY > window.innerHeight - 160) moveToShelf(m);
                }
                state.dragging = null;
                orbitControls.enabled = true;
            }
            state.isRotating = false;
            document.body.style.cursor = 'default';
        });

        window.addEventListener('wheel', e => {
            if(e.target.closest('canvas')) {
                if(state.viewMode==='F1') state.targetRotY += e.deltaY * 0.001;
                else state.targetRotX += e.deltaY * 0.001;
            }
        });

        function activateReticle(mesh) { reticleGroup.visible = true; }

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

        function animate() {
            requestAnimationFrame(animate);
            orbitControls.update();
            
            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;
            
            if(state.viewMode === 'F1') {
                filesGroup.rotation.y = state.currentRotY;
                filesGroup.position.y = state.currentPosY;
                filesGroup.rotation.x = 0; filesGroup.position.x = 0;
            } else {
                filesGroup.rotation.x = state.currentRotX;
                filesGroup.position.x = state.currentPosX;
                filesGroup.rotation.y = 0; filesGroup.position.y = 0;
            }

            starGroup.rotation.y += 0.0005; orbitGroup.rotation.x += 0.001; orbitGroup.rotation.y += 0.001;

            // Reticle Animation (Modified: 3 Layer Roulette)
            if(reticleGroup.visible && (state.selected || state.hovered)) {
                const t = state.selected || state.hovered;
                const wp = new THREE.Vector3(); t.getWorldPosition(wp);
                const wq = new THREE.Quaternion(); t.getWorldQuaternion(wq);
                reticleGroup.position.copy(wp);
                reticleGroup.quaternion.copy(wq);
                reticleGroup.translateZ(0.2);
                
                // Independent Rotation per layer
                if(reticleGroup.userData.r1) {
                    reticleGroup.userData.r1.rotation.z += 0.08; // Inner fast
                    reticleGroup.userData.r2.rotation.z -= 0.04; // Middle reverse medium
                    reticleGroup.userData.r3.rotation.z += 0.01; // Outer slow
                }

                const s = (state.selected && popupMesh) ? 3 : 1;
                reticleGroup.scale.set(s,s,1);
            } else {
                reticleGroup.visible = false;
            }

            drawHUD();
            composer.render();
        }
        animate();

        // --- HUD ---
        function drawHUD() {
            const dpr = window.devicePixelRatio||1;
            hudX.clearRect(0,0,hudC.width/dpr, hudC.height/dpr);
            const t = state.hovered || state.selected;
            if(!t || state.dragging) return;

            const p = new THREE.Vector3(); t.getWorldPosition(p);
            p.y += 2.0; p.project(camera);
            if(p.z > 1) return;

            const x = (p.x*0.5+0.5)*(hudC.width/dpr);
            const y = (p.y*-0.5+0.5)*(hudC.height/dpr);

            hudX.shadowBlur = 10; hudX.shadowColor = CONFIG.colCursor;
            hudX.strokeStyle = '#ffee00'; hudX.lineWidth = 2;
            hudX.beginPath(); hudX.moveTo(x,y); hudX.lineTo(x+40,y-40); hudX.lineTo(x+160,y-40); hudX.stroke();
            
            drawStrokeText(hudX, t.userData.handle.name, x+45, y-45, 20);
            hudX.shadowBlur = 0;
        }

        // --- Context Menu ---
        const ctxObj = document.getElementById('ctx-obj');
        const ctxBg = document.getElementById('ctx-bg');
        document.querySelectorAll('.ctx-item').forEach(el => {
            el.addEventListener('pointerdown', e => e.stopPropagation());
            el.addEventListener('click', e => e.stopPropagation());
        });
        document.querySelectorAll('.ctx-menu').forEach(el => el.addEventListener('pointerdown', e => e.stopPropagation()));

        window.addEventListener('contextmenu', e => {
            e.preventDefault(); 
            if(e.target.closest('.tree-node')) {
                showMenu(ctxObj, e.clientX, e.clientY);
                return;
            }
            if(!e.target.closest('canvas')) return;

            updateMouse(e); raycaster.setFromCamera(mouse, camera);
            const hits = raycaster.intersectObjects(state.objects, true);
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
            sfx.play('click');
            
            if(hits.length>0) {
                const t = getRoot(hits[0].object);
                state.selected = t; state.contextTarget = t.userData.handle;
                activateReticle(t); updatePopup(t);
                showMenu(ctxObj, e.clientX, e.clientY);
            } else {
                state.selected = null; state.contextTarget = null;
                updatePopup(null);
                showMenu(ctxBg, e.clientX, e.clientY);
            }
        });
        function showMenu(el, x, y) {
            el.style.display='flex';
            if(x+200>window.innerWidth) x-=200; if(y+200>window.innerHeight) y-=200;
            el.style.left=x+'px'; el.style.top=y+'px';
        }

        function getActionTarget() { return state.contextTarget || (state.selected ? state.selected.userData.handle : null); }

        document.getElementById('cm-open').onclick = () => {
            const t = getActionTarget();
            if(t) { if(t.kind==='directory') { enterFolder(t); updateTree(state.rootHandle); } else openPreview(t); }
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
        };
        document.getElementById('cm-copy').onclick = () => { state.clipboard = getActionTarget(); document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none'); };
        document.getElementById('cm-delete').onclick = async () => {
            const t = getActionTarget();
            if(t && confirm('Delete?')) { await state.currentHandle.removeEntry(t.name, {recursive:true}); enterFolder(state.currentHandle, false); }
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
        };
        document.getElementById('cm-rename').onclick = async () => {
            const t = getActionTarget();
            if(t) { const n = prompt("Rename:", t.name); if(n && t.move) { await t.move(n); enterFolder(state.currentHandle, false); } }
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
        };
        document.getElementById('cm-prop').onclick = async () => {
            const t = getActionTarget(); if(t) alert(`Name: ${t.name}\nKind: ${t.kind}`);
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
        };
        document.getElementById('cm-new').onclick = async () => {
            const n = prompt("New Folder Name:"); if(n) { await state.currentHandle.getDirectoryHandle(n, {create:true}); enterFolder(state.currentHandle, false); }
            document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
        };
        document.getElementById('cm-paste').onclick = async () => {
            if(state.clipboard && state.clipboard.kind === 'file') {
                const f = await state.clipboard.getFile();
                const h = await state.currentHandle.getFileHandle('Copy_'+f.name, {create:true});
                const w = await h.createWritable(); await w.write(f); await w.close();
                enterFolder(state.currentHandle, false);
            } document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
        };
        document.getElementById('cm-up').onclick = () => { goUp(); document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none'); };
        document.getElementById('cm-refresh').onclick = () => { enterFolder(state.currentHandle, false); document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none'); };

        window.addEventListener('dblclick', e => {
            if(!e.target.closest('canvas')) return;
            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.userData.isFolder) { enterFolder(t.userData.handle); updateTree(state.rootHandle); }
                else openPreview(t.userData.handle);
            }
        });

        // --- Sidebar/Shelf ---
        document.getElementById('sb-tab').onclick=()=>document.getElementById('sidebar').classList.toggle('closed');
        document.getElementById('shelf-tab').onclick=()=>document.getElementById('shelf').classList.toggle('closed');
        function moveToShelf(m) {
            filesGroup.remove(m); state.objects=state.objects.filter(o=>o!==m);
            if(popupMesh) { filesGroup.remove(popupMesh); popupMesh=null; }
            state.shelfData.push(m.userData.handle); renderShelf();
        }
        function renderShelf() {
            const s = document.getElementById('shelf'); s.innerHTML='';
            state.shelfData.forEach((h,i)=>{
                const d=document.createElement('div'); d.className='shelf-item'; d.draggable=true;
                d.innerHTML=`<div style="font-size:30px">📦</div><div style="font-size:10px">${h.name}</div>`;
                d.ondragstart=e=>e.dataTransfer.setData('i',i);
                s.appendChild(d);
            });
        }
        container.ondragover=e=>e.preventDefault();
        container.ondrop=e=>{
            e.preventDefault(); const i=e.dataTransfer.getData('i');
            if(i!==''){
                const h=state.shelfData[i]; state.shelfData.splice(i,1); renderShelf();
                createMonolith(h, 0, 0, 10, 0, 0, state.viewMode);
            }
        };

        // --- Preview ---
        const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body');
        const btnSave = document.getElementById('p-save');
        let currentFileHandle = null;

        document.getElementById('p-close').onclick=()=>pModal.classList.remove('active');
        btnSave.onclick = async () => {
            const ta = document.getElementById('editor-area');
            if(currentFileHandle && ta) {
                try {
                    const w = await currentFileHandle.createWritable();
                    await w.write(ta.value); await w.close();
                    alert("Saved!");
                } catch(e) { alert("Save failed."); }
            }
        };

        async function openPreview(handle) {
            currentFileHandle = handle;
            pModal.classList.add('active'); document.getElementById('p-title').innerText=handle.name;
            pBody.innerHTML='Loading...'; btnSave.style.display='none';
            try {
                const f = await handle.getFile();
                const u = URL.createObjectURL(f);
                if(f.name.match(/\.(png|jpg|webp|gif|bmp)$/i)) pBody.innerHTML=`<img src="${u}" style="max-width:100%;max-height:100%">`;
                else if(f.name.match(/\.(mp4|webm)$/i)) pBody.innerHTML=`<video src="${u}" controls autoplay style="max-width:100%"></video>`;
                else if(f.name.match(/\.(fbx|obj|glb)$/i)) pBody.innerHTML='[3D Model View]';
                else {
                    const t = await f.text();
                    pBody.innerHTML = `<textarea id="editor-area" class="hex-view">${t}</textarea>`;
                    btnSave.style.display = 'block';
                }
            } catch(e) { pBody.innerText="Error"; }
        }
        const treeC=document.getElementById('tree-container');
        async function updateTree(r) { treeC.innerHTML=''; treeC.appendChild(mkNode(r,0)); for await(const e of r.values()) if(e.kind==='directory') treeC.appendChild(mkNode(e,1)); }
        function mkNode(h,d) {
            const e=document.createElement('div'); e.className='tree-node'; e.style.paddingLeft=(d*15)+'px';
            e.innerHTML=`<span class="icon-tree">${h.kind==='directory'?'📂':'📄'}</span>${h.name}`;
            e.onclick=()=>enterFolder(h);
            e.oncontextmenu=(ev)=>{ ev.preventDefault(); ev.stopPropagation(); state.contextTarget=h; showMenu(ctxObj, ev.clientX, ev.clientY); };
            return e;
        }
    </script>
</body>
</html>

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

コメント

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

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