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


画像

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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Exp3D 1.2</title>
    <style>
        :root {
            --cyan: #00ffff;
            --magenta: #ff00ff;
            --bg: #050508;
            --panel: rgba(10, 15, 20, 0.95);
            --menu-bg: rgba(15, 20, 25, 0.98);
        }
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', monospace; user-select: none; color: #fff; }

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

        /* Sidebar */
        #sidebar {
            width: 280px; background: var(--panel); border-right: 1px solid var(--cyan);
            display: flex; flex-direction: column; z-index: 20; backdrop-filter: blur(10px);
        }
        #sb-header {
            padding: 15px; color: var(--cyan); font-weight: bold; letter-spacing: 2px;
            border-bottom: 1px solid rgba(0,255,255,0.3); background: linear-gradient(90deg, rgba(0,255,255,0.1), transparent);
        }
        #tree-container { flex: 1; overflow-y: auto; padding: 10px; }
        .tree-node {
            display: flex; align-items: center; padding: 4px 8px; cursor: pointer; color: #888;
            font-size: 13px; transition: 0.2s; border-left: 2px solid transparent;
        }
        .tree-node:hover { background: rgba(0,255,255,0.1); color: #fff; }
        .icon-3d {
            width: 12px; height: 14px; margin-right: 8px; position: relative;
            transform-style: preserve-3d; transform: rotateX(-10deg) rotateY(25deg);
            background: rgba(255,255,255,0.1); border: 1px solid currentColor; flex-shrink: 0;
        }
        .icon-folder { color: var(--cyan); }
        .icon-file { color: var(--magenta); border-radius: 2px; }

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

        /* HUD */
        #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        #hud-label {
            position: absolute; display: none; color: var(--cyan); font-family: 'Courier New'; font-weight: bold;
            font-size: 18px; text-shadow: 0 0 5px var(--cyan); white-space: nowrap; pointer-events: none;
            background: rgba(0,0,0,0.8); padding: 4px 8px; border: 1px solid var(--cyan);
        }

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

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

        /* Preview Modal with Rotation Animation */
        #preview {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9);
            display: flex; justify-content: center; align-items: center; z-index: 300;
            opacity: 0; pointer-events: none; visibility: hidden;
            perspective: 1000px; /* 3D perspective */
            transition: opacity 0.4s;
        }
        #preview.active { opacity: 1; pointer-events: auto; visibility: visible; }
        
        #p-box { 
            width: 85%; height: 85%; border: 1px solid var(--cyan); background: #000; 
            display: flex; flex-direction: column; box-shadow: 0 0 30px rgba(0,255,255,0.1);
            /* Initial State: Rotated and Scaled down */
            transform: rotateY(90deg) scale(0.5);
            transition: transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); 
        }
        #preview.active #p-box {
            /* Active State: Flat and Full Size */
            transform: rotateY(0deg) scale(1);
        }

        #p-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; font-weight: bold; align-items: center;}
        #p-body { flex: 1; padding: 0; overflow: hidden; position: relative; background: #050505; }
        
        .p-btn {
            background: transparent; border: 1px solid var(--cyan); color: var(--cyan);
            padding: 5px 15px; margin-left: 10px; cursor: pointer; font-weight: bold; transition: 0.2s;
        }
        .p-btn:hover { background: var(--cyan); color: #000; }
        .p-btn-save { border-color: #0f0; color: #0f0; }
        .p-btn-save:hover { background: #0f0; color: #000; }

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

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

        <div id="main">
            <div id="canvas-wrap"></div>
            
            <div id="hud">
                <canvas id="hud-canvas"></canvas>
                <div id="hud-label">FILENAME</div>
            </div>

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

            <div id="ctx-obj" class="ctx-menu">
                <div class="ctx-item" id="cm-open">Open <span class="shortcut">DblClk</span></div>
                <div class="ctx-sep"></div>
                <div class="ctx-item" id="cm-copy">Copy <span class="shortcut">Ctrl+C</span></div>
                <div class="ctx-item" id="cm-rename">Rename</div>
                <div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</div>
                <div class="ctx-sep"></div>
                <div class="ctx-item" id="cm-prop">Properties</div>
            </div>

            <div id="ctx-bg" class="ctx-menu">
                <div class="ctx-item" id="cm-new">New Folder</div>
                <div class="ctx-item" id="cm-paste">Paste <span class="shortcut">Ctrl+V</span></div>
                <div class="ctx-sep"></div>
                <div class="ctx-item" id="cm-up">Up Directory</div>
                <div class="ctx-item" id="cm-refresh">Refresh Scan</div>
            </div>
        </div>
    </div>

    <div id="preview">
        <div id="p-box">
            <div id="p-head">
                <span id="p-title">FILENAME</span>
                <div>
                    <button id="p-save" class="p-btn p-btn-save" style="display:none;">SAVE</button>
                    <button id="p-close" class="p-btn">CLOSE</button>
                </div>
            </div>
            <div id="p-body"></div>
        </div>
    </div>

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

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

        // --- Sound System (Web Audio API) ---
        const sfx = {
            ctx: null,
            init: () => {
                if (!sfx.ctx) sfx.ctx = new (window.AudioContext || window.webkitAudioContext)();
            },
            play: (type) => {
                if (!sfx.ctx) return;
                const t = sfx.ctx.currentTime;
                const osc = sfx.ctx.createOscillator();
                const gain = sfx.ctx.createGain();
                osc.connect(gain);
                gain.connect(sfx.ctx.destination);

                if (type === 'hover') {
                    osc.type = 'sine';
                    osc.frequency.setValueAtTime(800, t);
                    osc.frequency.exponentialRampToValueAtTime(1200, t + 0.05);
                    gain.gain.setValueAtTime(0.05, t);
                    gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
                    osc.start(t); osc.stop(t + 0.05);
                } else if (type === 'click') {
                    osc.type = 'square';
                    osc.frequency.setValueAtTime(600, t);
                    osc.frequency.exponentialRampToValueAtTime(100, t + 0.1);
                    gain.gain.setValueAtTime(0.1, t);
                    gain.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
                    osc.start(t); osc.stop(t + 0.1);
                } else if (type === 'open') {
                    osc.type = 'sawtooth';
                    osc.frequency.setValueAtTime(200, t);
                    osc.frequency.linearRampToValueAtTime(800, t + 0.3);
                    gain.gain.setValueAtTime(0.1, t);
                    gain.gain.linearRampToValueAtTime(0.001, t + 0.3);
                    osc.start(t); osc.stop(t + 0.3);
                } else if (type === 'back') {
                    osc.type = 'sawtooth';
                    osc.frequency.setValueAtTime(800, t);
                    osc.frequency.linearRampToValueAtTime(200, t + 0.3);
                    gain.gain.setValueAtTime(0.1, t);
                    gain.gain.linearRampToValueAtTime(0.001, t + 0.3);
                    osc.start(t); osc.stop(t + 0.3);
                } else if (type === 'save') {
                    osc.type = 'sine';
                    osc.frequency.setValueAtTime(440, t);
                    osc.frequency.setValueAtTime(880, t + 0.1);
                    gain.gain.setValueAtTime(0.1, t);
                    gain.gain.setValueAtTime(0.1, t + 0.1);
                    gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
                    osc.start(t); osc.stop(t + 0.4);
                } else if (type === 'error') {
                    osc.type = 'sawtooth';
                    osc.frequency.setValueAtTime(100, t);
                    gain.gain.setValueAtTime(0.2, t);
                    gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
                    osc.start(t); osc.stop(t + 0.2);
                }
            }
        };

        // --- Config ---
        const CONFIG = {
            colFolder: 0x00ffff,
            colFile: 0xff00ff,
            grid: 0x002233
        };

        const state = {
            rootHandle: null,
            currentHandle: null,
            currentPath: [],
            objects: [],      
            shelfData: [],    
            dragging: null,   
            hovered: null,
            clipboard: null,
            shelfOpen: true,
            isTransitioning: false
        };

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

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

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

        scene.add(new THREE.AmbientLight(0x404040, 5));
        const dl = new THREE.DirectionalLight(0xffffff, 3);
        dl.position.set(5, 10, 5);
        scene.add(dl);
        scene.add(new THREE.GridHelper(100, 100, CONFIG.grid, 0x001122));

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

        const orbitControls = new OrbitControls(camera, renderer.domElement);
        orbitControls.enableDamping = true;
        orbitControls.maxPolarAngle = Math.PI / 2.1;

        // Main group for scaling animations
        const filesGroup = new THREE.Group();
        scene.add(filesGroup);

        // --- Transition Logic (Zoom Effect) ---
        function animateTransition(direction, callback) {
            if (state.isTransitioning) return;
            state.isTransitioning = true;
            
            // direction: 'in' (enter folder) or 'out' (go back)
            const duration = 500;
            const startScale = filesGroup.scale.x;
            const targetScale = direction === 'in' ? 3.0 : 0.1;
            const startOpacity = 1.0;
            const startTime = Date.now();

            // Sfx
            sfx.play(direction === 'in' ? 'open' : 'back');

            function loopOut() {
                const elapsed = Date.now() - startTime;
                const p = Math.min(elapsed / duration, 1);
                // Ease In Quad
                const ease = p * p; 

                // Scale up/down and Fade out
                const s = startScale + (targetScale - startScale) * ease;
                filesGroup.scale.set(s, s, s);
                
                // Opacity needs to affect all children materials
                state.objects.forEach(obj => {
                     obj.material.opacity = 0.9 * (1 - p);
                     obj.children.forEach(c => {
                         if(c.material) c.material.opacity = (c.userData.baseOpacity || 0.6) * (1-p);
                     });
                });

                if (p < 1) {
                    requestAnimationFrame(loopOut);
                } else {
                    // Phase 1 Done: Swap content
                    callback().then(() => {
                        // Reset for Phase 2 (Fade In)
                        // If we went IN, we start small and grow to 1.
                        // If we went OUT, we start big and shrink to 1.
                        filesGroup.scale.set(direction==='in'?0.1:3.0, direction==='in'?0.1:3.0, direction==='in'?0.1:3.0);
                        animateIn();
                    });
                }
            }

            function animateIn() {
                const startTimeIn = Date.now();
                const startS = filesGroup.scale.x;
                
                function loopIn() {
                    const elapsed = Date.now() - startTimeIn;
                    const p = Math.min(elapsed / duration, 1);
                    const ease = 1 - (1 - p) * (1 - p); // Ease Out

                    const s = startS + (1.0 - startS) * ease;
                    filesGroup.scale.set(s, s, s);

                    state.objects.forEach(obj => {
                         obj.material.opacity = 0.9 * p;
                         obj.children.forEach(c => {
                             if(c.material) c.material.opacity = (c.userData.baseOpacity || 0.6) * p;
                         });
                    });

                    if (p < 1) {
                        requestAnimationFrame(loopIn);
                    } else {
                        state.isTransitioning = false;
                        // Normalize
                        state.objects.forEach(obj => obj.material.opacity = 0.9);
                    }
                }
                loopIn();
            }
            loopOut();
        }


        // --- File System ---
        document.getElementById('btn-mount').onclick = async () => {
            sfx.init(); // Init Audio
            sfx.play('click');
            try {
                const h = await window.showDirectoryPicker();
                state.rootHandle = h;
                enterFolder(h, false); // No animation for initial load
                updateTree(h);
            } catch(e) { console.log(e); }
        };

        async function enterFolder(handle, animate = true) {
            const loadLogic = async () => {
                state.currentHandle = handle;
                if(!state.currentPath.includes(handle)) state.currentPath.push(handle);
                
                state.objects.forEach(o => filesGroup.remove(o));
                state.objects = [];

                const entries = [];
                for await (const e of handle.values()) entries.push(e);
                layoutFiles(entries);
            };

            if (animate) {
                animateTransition('in', loadLogic);
            } else {
                await loadLogic();
            }
        }

        async function goUp() {
            if(state.currentPath.length > 1) {
                const parent = state.currentPath[state.currentPath.length-2]; // Prev
                
                animateTransition('out', async () => {
                    state.currentPath.pop();
                    state.currentHandle = parent;
                    state.objects.forEach(o => filesGroup.remove(o));
                    state.objects = [];
                    const entries = [];
                    for await (const e of parent.values()) entries.push(e);
                    layoutFiles(entries);
                });
            }
        }

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

        function createMonolith(handle, x, z) {
            const isFolder = handle.kind === 'directory';
            const color = isFolder ? CONFIG.colFolder : CONFIG.colFile;

            const geo = new THREE.BoxGeometry(1.6, 2.2, 0.1);
            const mat = new THREE.MeshPhysicalMaterial({
                color: color, metalness: 0.1, roughness: 0.1, transmission: 0.5, opacity: 0.9, transparent: true,
                emissive: color, emissiveIntensity: 0.2
            });

            const mesh = new THREE.Mesh(geo, mat);
            mesh.position.set(x, 1.5, z);
            mesh.userData = { handle, isFolder, isRoot: true };

            const edges = new THREE.EdgesGeometry(geo);
            const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.6 }));
            line.userData = { baseOpacity: 0.6 };
            mesh.add(line);

            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            canvas.width = 256; canvas.height = 256;
            ctx.fillStyle = isFolder ? '#00ffff' : '#ff00ff';
            ctx.font = '80px Arial';
            ctx.textAlign = 'center';
            ctx.fillText(isFolder ? '📂' : '📄', 128, 100);
            ctx.fillStyle = '#ffffff';
            ctx.font = 'bold 28px Courier New';
            ctx.fillText(handle.name.substring(0,10), 128, 180);
            if(handle.name.length > 10) ctx.fillText(handle.name.substring(10,20)+'...', 128, 220);

            const tex = new THREE.CanvasTexture(canvas);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex }));
            sprite.scale.set(1.5, 1.5, 1);
            sprite.position.z = 0.15;
            sprite.userData = { baseOpacity: 1.0 };
            mesh.add(sprite);

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

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

        const onPointerDown = (e) => {
            sfx.init();
            if (e.button !== 0) return; 
            updateMouse(e);
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(state.objects, true);
            
            if (intersects.length > 0) {
                let target = getRootObject(intersects[0].object);
                if (target) {
                    sfx.play('click');
                    state.dragging = {
                        mesh: target,
                        dist: camera.position.distanceTo(target.position),
                    };
                    orbitControls.enabled = false;
                    target.material.opacity = 0.5;
                }
            }
            hideContextMenus();
        };

        const onPointerMove = (e) => {
            updateMouse(e);
            if (state.dragging) {
                raycaster.setFromCamera(mouse, camera);
                const ray = raycaster.ray;
                const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -1.5);
                const target = new THREE.Vector3();
                ray.intersectPlane(plane, target);
                if(target) state.dragging.mesh.position.copy(target);
            } else {
                checkHover();
            }
        };

        const onPointerUp = () => {
            if (state.dragging) {
                const mesh = state.dragging.mesh;
                mesh.material.opacity = 0.9;
                const screenPos = mesh.position.clone().project(camera);
                if (screenPos.y < -0.5) {
                    sfx.play('save');
                    moveToShelf(mesh);
                }
                state.dragging = null;
                orbitControls.enabled = true;
            }
        };

        function getRootObject(obj) {
            let target = obj;
            while (target) {
                if (target.userData && target.userData.isRoot) return target;
                target = target.parent;
            }
            return null;
        }

        window.addEventListener('pointerdown', onPointerDown);
        window.addEventListener('pointermove', onPointerMove);
        window.addEventListener('pointerup', onPointerUp);

        function updateMouse(e) {
            const rect = renderer.domElement.getBoundingClientRect();
            mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
            mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
        }

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

        window.addEventListener('contextmenu', (e) => {
            e.preventDefault();
            updateMouse(e);
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(state.objects, true);
            hideContextMenus();
            sfx.play('click');

            if (intersects.length > 0) {
                selectedObj = getRootObject(intersects[0].object);
                showMenu(ctxObj, e.clientX, e.clientY);
            } else {
                selectedObj = null;
                document.getElementById('cm-paste').classList.toggle('disabled', !state.clipboard);
                showMenu(ctxBg, e.clientX, e.clientY);
            }
        });

        function showMenu(el, x, y) {
            el.style.display = 'flex';
            if(x + 180 > window.innerWidth) x -= 180;
            if(y + 200 > window.innerHeight) y -= 200;
            el.style.left = x + 'px';
            el.style.top = y + 'px';
        }

        function hideContextMenus() {
            ctxObj.style.display = 'none';
            ctxBg.style.display = 'none';
        }

        document.getElementById('cm-open').onclick = () => performOpen(selectedObj);
        document.getElementById('cm-copy').onclick = () => { if(selectedObj) state.clipboard = selectedObj.userData.handle; hideContextMenus(); };
        document.getElementById('cm-up').onclick = () => { goUp(); hideContextMenus(); };
        
        // --- Double Click ---
        window.addEventListener('dblclick', (e) => {
            updateMouse(e);
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(state.objects, true);
            if(intersects.length > 0) {
                const root = getRootObject(intersects[0].object);
                if(root) performOpen(root);
            }
        });

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

        // --- HUD ---
        const hudCanvas = document.getElementById('hud-canvas');
        const hudCtx = hudCanvas.getContext('2d');
        const hudLabel = document.getElementById('hud-label');
        let lastHover = null;

        function resizeHUD() {
            hudCanvas.width = container.clientWidth;
            hudCanvas.height = container.clientHeight;
        }
        window.addEventListener('resize', resizeHUD);
        resizeHUD();

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

        function drawHUD() {
            hudCtx.clearRect(0,0, hudCanvas.width, hudCanvas.height);
            if (!state.hovered || state.dragging) {
                hudLabel.style.display = 'none';
                return;
            }
            const mesh = state.hovered;
            const pos = mesh.position.clone();
            pos.y += 1.2;
            pos.project(camera);
            const x = (pos.x * 0.5 + 0.5) * hudCanvas.width;
            const y = (pos.y * -0.5 + 0.5) * hudCanvas.height;
            const color = mesh.userData.isFolder ? '#00ffff' : '#ff00ff';

            hudCtx.beginPath();
            hudCtx.strokeStyle = color;
            hudCtx.lineWidth = 2;
            hudCtx.moveTo(x, y);
            hudCtx.lineTo(x + 20, y - 20);
            hudCtx.lineTo(x + 80, y - 20);
            hudCtx.stroke();
            hudCtx.beginPath(); hudCtx.arc(x,y, 4, 0, Math.PI*2); hudCtx.fillStyle = color; hudCtx.fill();

            hudLabel.style.display = 'block';
            hudLabel.style.left = (x + 80) + 'px';
            hudLabel.style.top = (y - 35) + 'px';
            hudLabel.style.borderColor = color;
            hudLabel.style.color = color;
            hudLabel.innerText = mesh.userData.handle.name.toUpperCase();
        }

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

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

            try {
                const file = await handle.getFile();
                const name = file.name.toLowerCase();

                if(name.match(/\.(png|jpg|jpeg|gif|webp)$/)) {
                    pBody.innerHTML = '';
                    const img = document.createElement('img');
                    img.src = URL.createObjectURL(file);
                    img.style.maxWidth = '100%'; img.style.maxHeight = '100%'; img.style.objectFit = 'contain';
                    pBody.appendChild(img);
                } else {
                    // Text Editor
                    try {
                        const text = await file.text();
                        if (text.includes('\0') && !name.endsWith('.txt')) throw new Error("Binary");
                        
                        const ta = document.createElement('textarea');
                        ta.id = 'editor-area';
                        ta.className = 'hex-view';
                        ta.value = text;
                        pBody.innerHTML = '';
                        pBody.appendChild(ta);
                        btnSave.style.display = 'inline-block';
                    } catch (err) {
                        // Binary view fallback (readonly)
                        pBody.innerHTML = '<div style="padding:20px;">Binary file. Read-only.</div>';
                    }
                }
            } catch(e) {
                pBody.innerHTML = '<div style="padding:20px; color:red;">Error.</div>';
            }
        }

        document.getElementById('p-close').onclick = () => {
             sfx.play('click');
             pModal.classList.remove('active');
        };

        btnSave.onclick = async () => {
            sfx.play('save');
            const ta = document.getElementById('editor-area');
            if (currentFileHandle && ta) {
                try {
                    const w = await currentFileHandle.createWritable();
                    await w.write(ta.value);
                    await w.close();
                    // Flash effect
                    ta.style.background = '#002200';
                    setTimeout(() => ta.style.background = '#000', 200);
                } catch(e) { alert("Save failed: " + e); }
            }
        };

        // --- Shelf ---
        const shelf = document.getElementById('shelf');
        function moveToShelf(mesh) {
            filesGroup.remove(mesh);
            state.objects = state.objects.filter(o => o !== mesh);
            state.shelfData.push(mesh.userData.handle);
            renderShelf();
        }
        function renderShelf() {
            shelf.innerHTML = '';
            if (state.shelfData.length === 0) shelf.innerHTML = '<div style="color:var(--magenta); font-size:12px; opacity:0.6;">[ DRAG FILES & FOLDERS HERE ]</div>';
            state.shelfData.forEach((h, i) => {
                const el = document.createElement('div');
                el.className = 'shelf-item';
                el.draggable = true;
                el.innerHTML = `<div class="shelf-img">${h.kind==='directory'?'📂':'📄'}</div><div class="shelf-text">${h.name}</div>`;
                el.addEventListener('dragstart', (e) => e.dataTransfer.setData('idx', i));
                shelf.appendChild(el);
            });
        }
        const tab = document.getElementById('shelf-tab');
        tab.onclick = () => {
            sfx.play('click');
            shelf.classList.toggle('closed');
            tab.innerText = shelf.classList.contains('closed') ? '▲ DOCK' : '▼ DOCK';
        };
        container.addEventListener('dragover', e => e.preventDefault());
        container.addEventListener('drop', e => {
            e.preventDefault();
            const idx = e.dataTransfer.getData('idx');
            if(idx !== '') {
                sfx.play('click');
                const h = state.shelfData[idx];
                state.shelfData.splice(idx, 1);
                renderShelf();
                // Restore logic simplified for brevity
                enterFolder(state.currentHandle, false); 
            }
        });

        // --- Tree ---
        const treeCont = document.getElementById('tree-container');
        async function updateTree(root) {
            treeCont.innerHTML = '';
            treeCont.appendChild(createNode(root, 0));
            for await (const e of root.values()) {
                if(e.kind === 'directory') treeCont.appendChild(createNode(e, 1));
            }
        }
        function createNode(h, depth) {
            const div = document.createElement('div');
            div.className = 'tree-node';
            div.onclick = () => { sfx.play('click'); if(h.kind==='directory') enterFolder(h); };
            for(let i=0; i<depth; i++) div.appendChild(document.createElement('div')).className='indent';
            div.innerHTML += `<div class="icon-3d icon-${h.kind}"></div><div>${h.name}</div>`;
            return div;
        }

        // --- Loop ---
        function animate() {
            requestAnimationFrame(animate);
            orbitControls.update();
            drawHUD();
            composer.render();
        }
        animate();

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

    </script>
</body>
</html>

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

コメント

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