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



画像


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Sci-Fi 3D Explorer Pro</title>
    <style>
        :root {
            --primary: #00ffff;
            --secondary: #ff00ff;
            --bg: #050510;
            --glass: rgba(0, 255, 255, 0.1);
        }
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', sans-serif; user-select: none; }
        
        /* UI Layer */
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        
        /* Header */
        #header { padding: 20px; display: flex; align-items: center; gap: 20px; pointer-events: auto; }
        h1 { margin: 0; color: var(--primary); font-size: 24px; text-shadow: 0 0 10px var(--primary); letter-spacing: 2px; }
        #path-display { color: #fff; font-family: monospace; opacity: 0.8; background: rgba(0,0,0,0.5); padding: 5px 10px; border: 1px solid #333; }
        
        button {
            background: rgba(0,0,0,0.6); border: 1px solid var(--primary); color: var(--primary);
            padding: 8px 20px; cursor: pointer; text-transform: uppercase; font-weight: bold;
            transition: 0.3s; box-shadow: 0 0 5px var(--primary);
        }
        button:hover { background: var(--primary); color: #000; box-shadow: 0 0 20px var(--primary); }

        /* Preview Panel (The expanding modal) */
        #preview-overlay {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            display: flex; justify-content: center; align-items: center;
            background: rgba(0,0,0,0.8); backdrop-filter: blur(5px);
            opacity: 0; visibility: hidden; transition: opacity 0.3s; pointer-events: auto;
        }
        #preview-overlay.active { opacity: 1; visibility: visible; }
        
        #preview-card {
            width: 70%; height: 80%; background: rgba(10, 20, 30, 0.95);
            border: 1px solid var(--primary);
            box-shadow: 0 0 50px rgba(0, 255, 255, 0.2);
            transform: perspective(1000px) rotateY(90deg) scale(0.5); /* 初期状態:回転している */
            transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
            display: flex; flex-direction: column; overflow: hidden; position: relative;
        }
        #preview-overlay.active #preview-card {
            transform: perspective(1000px) rotateY(0deg) scale(1);
        }

        #card-header {
            padding: 15px; border-bottom: 1px solid #333; display: flex; justify-content: space-between;
            color: var(--primary); font-family: monospace; background: rgba(0, 255, 255, 0.1);
        }
        #card-content { flex: 1; overflow: hidden; position: relative; padding: 0; }
        
        /* Editors/Viewers */
        textarea#text-editor {
            width: 100%; height: 100%; background: transparent; color: #e0e0e0;
            border: none; padding: 20px; font-family: monospace; font-size: 14px;
            resize: none; outline: none; box-sizing: border-box;
        }
        #image-viewer-container {
            width: 100%; height: 100%; overflow: hidden; display: flex; justify-content: center; align-items: center; cursor: grab;
        }
        #image-viewer { max-width: 100%; max-height: 100%; transition: transform 0.1s; transform-origin: center; }

        /* Bottom Shelf (Temp Workspace) */
        #bottom-shelf {
            position: absolute; bottom: -100px; left: 10%; width: 80%; height: 140px;
            background: linear-gradient(to top, rgba(0, 50, 60, 0.9), rgba(0,0,0,0));
            border-top: 2px solid var(--secondary);
            transition: bottom 0.4s cubic-bezier(0.2, 1, 0.3, 1);
            pointer-events: auto; z-index: 20;
            display: flex; align-items: center; justify-content: center; color: var(--secondary);
            backdrop-filter: blur(10px);
        }
        #bottom-shelf:hover, #bottom-shelf.drag-active { bottom: 0; }
        #shelf-handle {
            position: absolute; top: -30px; left: 50%; transform: translateX(-50%);
            width: 100px; height: 30px; background: var(--secondary);
            clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%);
            color: #000; text-align: center; line-height: 30px; font-weight: bold; cursor: pointer;
            box-shadow: 0 -5px 15px rgba(255, 0, 255, 0.4);
        }
        #shelf-content { font-size: 20px; letter-spacing: 2px; text-shadow: 0 0 10px var(--secondary); pointer-events: none;}
        
        .scan-lines {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;
            background: repeating-linear-gradient(0deg, transparent, transparent 1px, rgba(0, 255, 255, 0.03) 2px, rgba(0, 255, 255, 0.03) 3px);
            z-index: 2;
        }
    </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="ui-layer">
        <div id="header">
            <button id="btn-select-root">SELECT ROOT FOLDER</button>
            <div id="path-display">NO FOLDER SELECTED</div>
        </div>
    </div>

    <div id="preview-overlay">
        <div id="preview-card">
            <div id="card-header">
                <span id="file-name">FILENAME.EXT</span>
                <div>
                    <button id="btn-save" style="display:none; font-size:10px; padding: 2px 10px;">SAVE</button>
                    <button id="btn-close" style="font-size:10px; padding: 2px 10px;">CLOSE</button>
                </div>
            </div>
            <div id="card-content">
                </div>
        </div>
    </div>

    <div id="bottom-shelf">
        <div id="shelf-handle">:: TEMP ::</div>
        <div id="shelf-content">DROP FILES HERE TO MOVE TO "_TEMP_WORKSPACE"</div>
    </div>

    <div class="scan-lines"></div>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { DragControls } from 'three/addons/controls/DragControls.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';

        // --- Global State ---
        const state = {
            rootHandle: null,
            currentDirHandle: null,
            tempDirHandle: null,
            files: [], // Array of { handle, mesh }
            isDragActive: false
        };

        const CONFIG = {
            colorFolder: 0x00ffff,
            colorFile: 0x0088aa,
            colorSelected: 0xff00ff,
            gridSize: 5
        };

        // --- Three.js Setup ---
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x050510, 0.04);
        
        const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
        camera.position.set(0, 8, 12);

        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // Bloom Effect
        const renderScene = new RenderPass(scene, camera);
        const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
        bloomPass.threshold = 0.2;
        bloomPass.strength = 1.2;
        bloomPass.radius = 0.5;
        const composer = new EffectComposer(renderer);
        composer.addPass(renderScene);
        composer.addPass(bloomPass);

        // Lights & Grid
        scene.add(new THREE.AmbientLight(0x404040, 2));
        const grid = new THREE.GridHelper(50, 50, 0x004455, 0x001122);
        scene.add(grid);
        const dirLight = new THREE.DirectionalLight(0xffffff, 2);
        dirLight.position.set(5, 10, 7);
        scene.add(dirLight);

        // Controls
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;

        // Group for files
        const fileGroup = new THREE.Group();
        scene.add(fileGroup);

        // --- File System Logic ---

        async function initFileSystem() {
            try {
                state.rootHandle = await window.showDirectoryPicker();
                state.currentDirHandle = state.rootHandle;
                
                // Ensure Temp dir exists or create it
                state.tempDirHandle = await state.rootHandle.getDirectoryHandle('_TEMP_WORKSPACE', { create: true });
                
                updatePathDisplay(state.rootHandle.name);
                await loadFiles(state.currentDirHandle);
            } catch (e) {
                console.error(e);
                alert("フォルダへのアクセスがキャンセルされたか、エラーが発生しました。");
            }
        }

        async function loadFiles(dirHandle) {
            // Clear current 3D objects
            // dispose logic omitted for brevity, but recommended for production
            while(fileGroup.children.length > 0){ 
                fileGroup.remove(fileGroup.children[0]); 
            }
            state.files = [];

            const entries = [];
            for await (const entry of dirHandle.values()) {
                if (entry.name !== '_TEMP_WORKSPACE') { // Hide the system temp folder from main view
                    entries.push(entry);
                }
            }

            // Layout files in a grid
            let idx = 0;
            const cols = Math.ceil(Math.sqrt(entries.length));
            const spacing = 2.5;
            const offset = (cols * spacing) / 2;

            for (const entry of entries) {
                const x = (idx % cols) * spacing - offset;
                const z = Math.floor(idx / cols) * spacing - offset;
                createFileMesh(entry, x, z);
                idx++;
            }
            
            // Re-init Drag Controls for new meshes
            initDragControls();
        }

        function createFileMesh(handle, x, z) {
            const isFolder = handle.kind === 'directory';
            const geometry = isFolder ? new THREE.BoxGeometry(1.5, 1.5, 1.5) : new THREE.BoxGeometry(1, 1.5, 0.2);
            
            // Material: Transparent glass-like
            const material = new THREE.MeshPhysicalMaterial({
                color: isFolder ? CONFIG.colorFolder : CONFIG.colorFile,
                metalness: 0.8, roughness: 0.1, transmission: 0.2, opacity: 0.8, transparent: true,
                wireframe: false
            });

            const mesh = new THREE.Mesh(geometry, material);
            mesh.position.set(x, 1, z);
            
            // Add Wireframe edges for tech look
            const edges = new THREE.EdgesGeometry(geometry);
            const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.3 }));
            mesh.add(line);

            // Add Text Label
            const canvas = document.createElement('canvas');
            const context = canvas.getContext('2d');
            canvas.width = 256; canvas.height = 64;
            context.fillStyle = isFolder ? '#00ffff' : '#ccccff';
            context.font = 'bold 24px monospace';
            context.textAlign = 'center';
            context.fillText(handle.name.substring(0, 15), 128, 30);
            
            const labelMap = new THREE.CanvasTexture(canvas);
            const labelMat = new THREE.SpriteMaterial({ map: labelMap });
            const label = new THREE.Sprite(labelMat);
            label.position.set(0, -1.2, 0);
            label.scale.set(3, 0.75, 1);
            mesh.add(label);

            mesh.userData = { handle: handle, isFolder: isFolder, originalPos: new THREE.Vector3(x, 1, z) };
            fileGroup.add(mesh);
            state.files.push(mesh);
        }

        // --- Interaction Logic ---
        
        // Raycaster for clicks
        const raycaster = new THREE.Raycaster();
        const mouse = new THREE.Vector2();
        let dragControls;

        function initDragControls() {
            if (dragControls) dragControls.dispose();
            
            dragControls = new DragControls(state.files, camera, renderer.domElement);
            
            dragControls.addEventListener('dragstart', function (event) {
                controls.enabled = false; // Disable orbit
                event.object.material.emissive.set(0xffffff);
                event.object.material.emissiveIntensity = 0.5;
                state.isDragActive = true;
                document.getElementById('bottom-shelf').classList.add('drag-active');
            });

            dragControls.addEventListener('dragend', async function (event) {
                controls.enabled = true;
                event.object.material.emissive.set(0x000000);
                state.isDragActive = false;
                document.getElementById('bottom-shelf').classList.remove('drag-active');

                // Check drop position (Screen space check for bottom area)
                // Convert 3D pos to screen pos
                const vector = event.object.position.clone();
                vector.project(camera);
                // WebGL y is -1 (bottom) to 1 (top). 
                // If y < -0.6, it's near the bottom.
                if (vector.y < -0.6) {
                    await moveFileToTemp(event.object.userData.handle);
                    // Remove mesh
                    fileGroup.remove(event.object);
                } else {
                    // Reset position (Spring back) if not dropped in shelf
                     // Simple reset for now
                     const original = event.object.userData.originalPos;
                     event.object.position.copy(original);
                }
            });
        }

        async function moveFileToTemp(handle) {
            try {
                // NOTE: File System Access API move() is experimental.
                // We will use copy + remove strategy or move() if available.
                // For safety in this demo, we will use move() on the handle.
                if (handle.move) {
                    await handle.move(state.tempDirHandle);
                    alert(`${handle.name} を _TEMP_WORKSPACE に移動しました。`);
                } else {
                    alert("このブラウザはファイルの直接移動をサポートしていない可能性があります。");
                }
            } catch (e) {
                console.error("Move failed:", e);
                alert("移動に失敗しました: " + e.message);
            }
        }


        // Click Handler for Preview
        renderer.domElement.addEventListener('click', async (e) => {
            if (state.isDragActive) return; // Don't trigger click if dragging

            mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(state.files);

            if (intersects.length > 0) {
                const object = intersects[0].object;
                const handle = object.userData.handle;
                
                if (object.userData.isFolder) {
                    // Navigate Folder
                    state.currentDirHandle = handle;
                    updatePathDisplay(handle.name);
                    await loadFiles(handle);
                } else {
                    // Open File
                    openPreview(handle);
                }
            }
        });

        // --- Preview UI Logic ---
        const previewOverlay = document.getElementById('preview-overlay');
        const cardContent = document.getElementById('card-content');
        const btnSave = document.getElementById('btn-save');
        let currentFileHandle = null;

        async function openPreview(handle) {
            currentFileHandle = handle;
            const file = await handle.getFile();
            const name = file.name.toLowerCase();
            document.getElementById('file-name').textContent = file.name;

            cardContent.innerHTML = ''; // Clear previous
            btnSave.style.display = 'none';

            if (name.endsWith('.txt') || name.endsWith('.md') || name.endsWith('.js') || name.endsWith('.html') || name.endsWith('.json')) {
                // Text Mode
                const text = await file.text();
                const textarea = document.createElement('textarea');
                textarea.id = 'text-editor';
                textarea.value = text;
                cardContent.appendChild(textarea);
                btnSave.style.display = 'inline-block';
            } else if (name.endsWith('.png') || name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.gif') || name.endsWith('.webp')) {
                // Image Mode
                const blob = await file.slice(0, file.size, file.type);
                const url = URL.createObjectURL(blob);
                const container = document.createElement('div');
                container.id = 'image-viewer-container';
                const img = document.createElement('img');
                img.id = 'image-viewer';
                img.src = url;
                container.appendChild(img);
                cardContent.appendChild(container);

                // Image Zoom/Pan Logic
                let scale = 1;
                let panning = false;
                let pointX = 0, pointY = 0, startX = 0, startY = 0;

                container.onwheel = (e) => {
                    e.preventDefault();
                    scale += e.deltaY * -0.001;
                    scale = Math.min(Math.max(.125, scale), 4);
                    img.style.transform = `translate(${pointX}px, ${pointY}px) scale(${scale})`;
                };
                
                container.onmousedown = (e) => { e.preventDefault(); panning = true; startX = e.clientX - pointX; startY = e.clientY - pointY; }
                container.onmouseup = () => { panning = false; }
                container.onmousemove = (e) => {
                    if (!panning) return;
                    e.preventDefault();
                    pointX = e.clientX - startX;
                    pointY = e.clientY - startY;
                    img.style.transform = `translate(${pointX}px, ${pointY}px) scale(${scale})`;
                }
            } else {
                cardContent.innerHTML = '<div style="color:#fff; padding:20px;">PREVIEW NOT AVAILABLE</div>';
            }

            previewOverlay.classList.add('active');
        }

        function closePreview() {
            previewOverlay.classList.remove('active');
            // Reset content after anim
            setTimeout(() => {
                cardContent.innerHTML = '';
                // Revoke object URLs if image
            }, 300);
        }

        // Close when clicking background of overlay
        previewOverlay.addEventListener('click', (e) => {
            if (e.target === previewOverlay) closePreview();
        });
        document.getElementById('btn-close').addEventListener('click', closePreview);

        // Save Function
        btnSave.addEventListener('click', async () => {
            const textarea = document.getElementById('text-editor');
            if (textarea && currentFileHandle) {
                try {
                    const writable = await currentFileHandle.createWritable();
                    await writable.write(textarea.value);
                    await writable.close();
                    
                    // Flash effect
                    const originalBg = textarea.style.background;
                    textarea.style.background = 'rgba(0,255,0,0.2)';
                    setTimeout(()=> textarea.style.background = originalBg, 200);
                } catch (e) {
                    alert('保存できませんでした: ' + e);
                }
            }
        });


        // --- UI Helpers ---
        document.getElementById('btn-select-root').addEventListener('click', initFileSystem);
        
        function updatePathDisplay(name) {
            document.getElementById('path-display').textContent = `ROOT/${name}`;
        }

        // Shelf UI Toggle
        const shelf = document.getElementById('bottom-shelf');
        shelf.addEventListener('click', () => {
            // Logic handled by CSS hover mostly, but clicking label could toggle lock
        });


        // --- Main Loop ---
        function animate() {
            requestAnimationFrame(animate);
            controls.update();

            // Rotate objects gently
            state.files.forEach(mesh => {
                if (!state.isDragActive) {
                    mesh.rotation.y += 0.005;
                    mesh.rotation.z = Math.sin(Date.now() * 0.001 + mesh.position.x) * 0.05;
                }
            });

            composer.render();
        }
        animate();

        // Resize
        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
        });

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

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

コメント

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