SF映画っぽいエクスプローラ「Exp3D 1.5」
ダウンロードされる方はこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Exp3D 1.5.2</title>
<style>
:root {
--cyan: #00ffff;
--magenta: #ff00ff;
--orange: #ffaa00;
--yellow: #ffee00;
--ocher: #ccaa00;
--dark-ocher: #886600;
--blue: #0088ff;
--bg: #000000;
--panel: rgba(5, 10, 15, 0.95);
}
body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', monospace; user-select: none; color: #fff; }
#app { display: flex; width: 100vw; height: 100vh; position: relative; overflow: hidden; }
/* Panes */
#pane-tree {
flex: 0 0 25%; position: relative; overflow: hidden;
border-right: 1px solid #333;
background: rgba(0,0,0,0.3);
z-index: 10;
display: flex; flex-direction: column;
pointer-events: auto;
}
#pane-file {
flex: 1; position: relative; overflow: hidden;
background: transparent;
z-index: 10; pointer-events: none;
}
.pane-label, .sys-btn, .stock-item, .stock-tab-item, #stock, #stock-tabs-container, #pagination-ctrl, .tree-node, .view-switch-btn, #btn-select-root {
pointer-events: auto;
}
/* Splitter */
#splitter {
width: 6px; cursor: col-resize; background: #111; border-left: 1px solid #444; border-right: 1px solid #444;
display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100;
transition: background 0.2s; pointer-events: auto;
}
#splitter:hover, #splitter.active { background: var(--orange); }
.grip { width: 2px; height: 4px; background: #666; margin: 2px 0; }
#canvas-wrap {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 0; pointer-events: auto;
}
canvas { display: block; outline: none; }
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
#hud-canvas { width: 100%; height: 100%; display: block; }
/* Tree Header */
#tree-header {
height: 40px; background: linear-gradient(to bottom, rgba(0,20,40,0.9), transparent);
display: flex; align-items: center; padding: 0 10px; flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
gap: 10px;
}
.tree-title { font-size: 14px; font-weight: bold; color: var(--cyan); letter-spacing: 1px; white-space: nowrap; }
#btn-select-root {
background: rgba(0,0,0,0.5); border: 1px solid var(--cyan); color: var(--cyan);
padding: 2px 8px; font-size: 11px; cursor: pointer; transition: 0.2s; white-space: nowrap; font-weight: bold;
}
#btn-select-root:hover { background: var(--cyan); color: #000; box-shadow: 0 0 5px var(--cyan); }
.view-switch { display: flex; gap: 2px; }
.view-switch-btn {
background: rgba(0,0,0,0.5); border: 1px solid #444; color: #666;
padding: 2px 8px; font-size: 11px; cursor: pointer; transition: 0.2s;
}
.view-switch-btn:hover { color: #fff; border-color: #888; }
.view-switch-btn.active { background: var(--ocher); color: #000; border-color: var(--ocher); font-weight: bold; box-shadow: 0 0 5px var(--ocher); }
#top-bar {
position: absolute; top: 0; left: 0; width: 100%; height: 40px;
display: flex; align-items: center; justify-content: flex-end; padding: 0 20px;
z-index: 60; pointer-events: none;
}
.pane-label {
position: absolute; bottom: 10px; left: 10px; font-size: 12px;
color: rgba(255,255,255,0.3); pointer-events: none; z-index: 10;
}
#mode-info {
position: absolute; top: 10px; right: 20px; text-align: right; pointer-events: none;
color: var(--yellow); text-shadow: 0 0 5px var(--yellow); font-weight: bold; font-size: 16px; z-index: 20;
}
#pagination-ctrl {
position: absolute; top: 35px; right: 20px;
display: flex; align-items: center; gap: 5px;
z-index: 60;
}
.page-btn {
background: rgba(0,0,0,0.5); border: 1px solid var(--blue); color: var(--blue);
width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;
cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 12px;
}
.page-btn:hover { background: var(--blue); color: #000; }
.page-btn.disabled { opacity: 0.3; pointer-events: none; border-color: #555; color: #555; }
#page-display {
font-size: 14px; font-weight: bold; color: var(--blue); min-width: 50px; text-align: center;
}
/* 2D Tree */
#tree-container {
padding: 10px; overflow-y: auto; flex: 1; box-sizing: border-box;
display: none;
background: rgba(0,0,0,0.3);
}
#tree-container::-webkit-scrollbar { width: 8px; }
#tree-container::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); }
#tree-container::-webkit-scrollbar-thumb { background: #333; border: 1px solid #444; }
#tree-container::-webkit-scrollbar-thumb:hover { background: var(--ocher); }
.tree-node {
padding: 4px 8px; color: #aaa; cursor: pointer; display: flex; align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.05); transition: background 0.2s; font-size: 13px;
}
.tree-node:hover { background: rgba(0,255,255,0.1); color: #fff; }
.tree-icon { margin-right: 8px; font-size: 14px; color: var(--ocher); display: inline-block; width: 16px; text-align: center; }
#loader {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.85); display: flex; flex-direction: column;
justify-content: center; align-items: center; z-index: 200;
display: none; pointer-events: auto;
}
#loader.active { display: flex; }
.loader-text { color: var(--cyan); font-size: 24px; margin-bottom: 20px; animation: blink 1s infinite; letter-spacing: 2px; }
@keyframes blink { 50% { opacity: 0.5; } }
#stock {
position: absolute; bottom: 0; left: 0; right: 0; height: 180px;
background: linear-gradient(to top, rgba(20,10,0,0.95), transparent);
border-top: 2px solid var(--orange); display: flex; align-items: center;
padding: 0 50px; gap: 40px;
z-index: 40; transition: bottom 0.3s; overflow-x: hidden;
cursor: grab; pointer-events: auto;
}
#stock.active { cursor: grabbing; }
#stock.closed { bottom: -180px; }
#stock-tabs-container {
position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%);
display: flex; gap: 5px; z-index: 41; transition: bottom 0.3s; pointer-events: auto;
}
#stock.closed + #stock-tabs-container { bottom: 0; }
.stock-tab-item {
background: rgba(50, 20, 0, 0.8); color: #aaa;
padding: 2px 20px; font-weight: bold; font-size: 12px;
cursor: pointer; clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%);
border-bottom: none; transition: 0.2s; min-width: 60px; text-align: center;
border-top: 2px solid #553300; pointer-events: auto;
}
.stock-tab-item:hover { color: #fff; background: rgba(100, 50, 0, 0.8); }
.stock-tab-item.active { background: var(--orange); color: #000; border-top: 2px solid #fff; }
.stock-item {
width: 100px; height: 130px;
background: rgba(0,0,0,0.3); border: 1px solid var(--orange);
display: flex; flex-direction: column; align-items: center;
cursor: grab; color: var(--orange); flex-shrink: 0; position: relative;
transition: 0.2s; user-select: none; pointer-events: auto;
}
.stock-item:hover { background: rgba(255,170,0,0.1); box-shadow: 0 0 10px rgba(255,170,0,0.2); }
.stock-thumb { width: 90px; height: 90px; margin-top: 5px; object-fit: contain; background: #000; display:flex; justify-content:center; align-items:center; overflow:hidden; }
.stock-label { font-size: 11px; margin-top: 5px; width: 90px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: bold; }
.ctx-menu {
position: fixed; display: none; flex-direction: column;
background: rgba(10, 15, 20, 0.98); border: 1px solid var(--cyan);
box-shadow: 0 0 20px rgba(0,255,255,0.3); min-width: 200px; z-index: 9999;
transform-origin: top; padding: 5px 0; overflow: hidden; pointer-events: auto;
}
.ctx-menu.opening { animation: menuOpen 0.2s ease-out forwards; }
@keyframes menuOpen { from { transform: scaleY(0); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
.ctx-item { padding: 10px 15px; cursor: pointer; color: #ccc; display: flex; justify-content: space-between; border-bottom: 1px solid #333; transition: background 0.1s; }
.ctx-item:hover { background: var(--cyan); color: #000; }
.ctx-menu.closing .ctx-item { animation: itemFold 0.2s forwards ease-in; }
@keyframes itemFold { 0% { transform: translateY(0); opacity: 1; height: 40px; } 100% { transform: translateY(-20px); opacity: 0; height: 0; padding: 0; border: none; } }
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8);
display: flex; justify-content: center; align-items: center; z-index: 300;
opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
perspective: 1500px;
}
.modal-overlay.active { opacity: 1; pointer-events: auto; visibility: visible; }
.modal-box {
border: 1px solid var(--cyan); background: #000;
display: flex; flex-direction: column; box-shadow: 0 0 50px rgba(0,255,255,0.2);
transform-style: preserve-3d; opacity: 0; transform-origin: center;
}
.modal-overlay.opening .modal-box { animation: animFlipOpen 0.6s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
.modal-overlay.closing .modal-box { animation: animFlipClose 0.5s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
@keyframes animFlipOpen { 0% { transform: translateZ(-500px) rotateX(180deg); opacity: 0; } 100% { transform: translateZ(0) rotateX(0deg); opacity: 1; } }
@keyframes animFlipClose { 0% { transform: translateZ(0) rotateX(0deg); opacity: 1; } 100% { transform: translateZ(-500px) rotateX(-180deg); opacity: 0; } }
.modal-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; align-items: center; font-weight: bold; }
.modal-body { flex: 1; padding: 0; overflow: hidden; position: relative; background: #050505; display: flex; justify-content: center; align-items: center; }
.p-btn { background: transparent; border: 1px solid var(--cyan); color: var(--cyan); padding: 5px 15px; margin-left: 10px; cursor: pointer; transition: 0.2s; }
.p-btn:hover { background: var(--cyan); color: #000; }
.p-btn-save { display: none; border-color: #0f0; color: #0f0; }
#p-box { width: 90%; height: 90%; }
.hex-view { width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; border: none; resize: none; font-family: 'Courier New', monospace; outline: none; }
#d-box { width: 400px; height: 200px; }
#d-body { flex-direction: column; padding: 20px; gap: 20px; }
.d-input { width: 100%; background: #111; border: 1px solid #333; color: #fff; padding: 10px; font-family: 'Segoe UI', monospace; font-size: 16px; outline: none; text-align: center; }
.d-input:focus { border-color: var(--cyan); }
.d-actions { display: flex; gap: 10px; width: 100%; justify-content: center; }
.scanlines {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;
background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
background-size: 100% 3px; z-index: 9000; opacity: 0.15;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="app">
<div id="canvas-wrap"></div>
<div id="pane-tree">
<div id="tree-header">
<span class="tree-title">EXP3D SYSTEM</span>
<button id="btn-select-root">SELECT ROOT</button>
<div class="view-switch">
<button id="btn-view-2d" class="view-switch-btn">2D</button>
<button id="btn-view-3d" class="view-switch-btn active">3D</button>
</div>
</div>
<div id="tree-container"></div>
</div>
<div id="splitter">
<div class="grip"></div><div class="grip"></div><div class="grip"></div>
</div>
<div id="pane-file">
<div class="pane-label">SYSTEM // OBJECTS</div>
<div id="hud"><canvas id="hud-canvas"></canvas></div>
<div id="stock" class="closed">
<div id="stock-hint" style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>
</div>
<div id="stock-tabs-container">
<div id="stock-tab-placeholder" class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>
</div>
<div id="mode-info">MODE: F1 (VERTICAL)</div>
<div id="pagination-ctrl">
<div id="btn-prev" class="page-btn"><</div>
<div id="page-display">1 / 1</div>
<div id="btn-next" class="page-btn">></div>
</div>
</div>
<div id="top-bar"></div>
<div id="loader">
<div class="loader-text">READING DATA...</div>
<button id="btn-cancel-load" class="p-btn" style="border-color:#ff5555; color:#ff5555;">CLOSE</button>
</div>
<div id="ctx-obj" class="ctx-menu">
<div class="ctx-item" id="cm-open">Open</div>
<div class="ctx-item" id="cm-expand" style="color:var(--yellow); display:none;">Expand (Force)</div>
<div class="ctx-item" id="cm-collapse" style="color:var(--yellow); display:none;">Collapse</div>
<div class="ctx-item" id="cm-copy">Copy</div>
<div class="ctx-item" id="cm-rename">Rename</div>
<div class="ctx-item" id="cm-add-stock" style="color:var(--orange)">Add to Stock</div>
<div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</div>
</div>
<div id="ctx-bg" class="ctx-menu">
<div class="ctx-item" id="cm-new">New Folder</div>
<div class="ctx-item" id="cm-paste">Paste</div>
<div class="ctx-item" id="cm-up">Up Directory</div>
<div class="ctx-item" id="cm-refresh">Refresh</div>
</div>
<div id="ctx-tree-bg" class="ctx-menu">
<div class="ctx-item" id="cm-tree-up">Up Directory</div>
<div class="ctx-item" id="cm-tree-refresh">Refresh</div>
</div>
<div id="ctx-tab" class="ctx-menu">
<div class="ctx-item" id="cm-tab-hide" style="color:var(--orange)">Hide</div>
<div class="ctx-item" id="cm-tab-cancel" style="color:#ff5555">Cancel</div>
</div>
</div>
<div id="preview" class="modal-overlay">
<div id="p-box" class="modal-box">
<div class="modal-head">
<span id="p-title">FILE</span>
<div>
<button id="p-save" class="p-btn p-btn-save">SAVE</button>
<button id="p-close" class="p-btn">CLOSE</button>
</div>
</div>
<div id="p-body" class="modal-body"></div>
</div>
</div>
<div id="dialog" class="modal-overlay">
<div id="d-box" class="modal-box">
<div class="modal-head">
<span id="d-title">INPUT</span>
</div>
<div id="d-body" class="modal-body">
<input type="text" id="d-input" class="d-input" spellcheck="false" autocomplete="off">
<div class="d-actions">
<button id="d-ok" class="p-btn" style="border-color:var(--cyan); color:var(--cyan); width:100px;">OK</button>
<button id="d-cancel" class="p-btn" style="border-color:#ff5555; color:#ff5555; width:100px;">CANCEL</button>
</div>
</div>
</div>
</div>
<div class="scanlines"></div>
<script type="module">
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const CONFIG = {
colFolderIdle: 0x886600,
colFolderActive: 0xffff00,
colFile: 0x0088ff,
treeCamOffset: { x:0, y:5, z:40 },
maxTreeChildren: 100,
itemsPerPage: 20
};
const state = {
splitPercent: 30, draggingSplit: false, activePane: 'none',
treeMode: '3D',
rootHandle: null, currentHandle: null,
treeNodes: [], fileObjects: [],
currentAllEntries: [], currentPage: 1, totalPages: 1,
activeNode: null,
stockList: [], activeStockIdx: -1, clipboard: null,
viewMode: 'F1',
targetRotX: 0, currentRotX: 0, targetRotY: 0, currentRotY: 0,
targetPosX: 0, currentPosX: 0, targetPosY: 0, currentPosY: 0,
targetPosZ: 0, currentPosZ: 0,
treeScrollZ: 0, targetTreeScrollZ: 0, treeScrollX: 0, targetTreeScrollX: 0,
mouse: new THREE.Vector2(), lastMouse: new THREE.Vector2(),
hovered: null, selected: null, dragging: null, dragStart: new THREE.Vector2(),
contextTarget: null, tabContextTargetIdx: -1, isLoading: false
};
const sfx = {
ctx: null,
init: () => { if (!sfx.ctx) sfx.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
play: (type) => {
if (!sfx.ctx) return;
const t = sfx.ctx.currentTime; const o = sfx.ctx.createOscillator(); const g = sfx.ctx.createGain();
o.connect(g); g.connect(sfx.ctx.destination);
if(type==='click'){o.frequency.setValueAtTime(600,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
else if(type==='hover'){o.frequency.setValueAtTime(300,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.05);o.start(t);o.stop(t+0.05);}
else if(type==='open'){o.type='triangle';o.frequency.setValueAtTime(200,t);o.frequency.linearRampToValueAtTime(600,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
else if(type==='back'){o.type='triangle';o.frequency.setValueAtTime(600,t);o.frequency.linearRampToValueAtTime(200,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
else if(type==='lock'){o.type='square';o.frequency.setValueAtTime(1000,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
else if(type==='error'){o.type='sawtooth';o.frequency.setValueAtTime(100,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.3);o.start(t);o.stop(t+0.3);}
}
};
const container = document.getElementById('canvas-wrap');
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.autoClear = false;
renderer.toneMapping = THREE.ReinhardToneMapping;
renderer.toneMappingExposure = 1.3;
container.appendChild(renderer.domElement);
const sceneTree = new THREE.Scene(); sceneTree.fog = new THREE.FogExp2(0x020205, 0.008);
const camTree = new THREE.PerspectiveCamera(60, 1, 0.1, 2000);
const groupTree = new THREE.Group(); sceneTree.add(groupTree);
const sceneFile = new THREE.Scene(); sceneFile.fog = new THREE.FogExp2(0x020205, 0.015);
const camFile = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
camFile.position.set(0, 0, 0.1);
const groupFile = new THREE.Group(); sceneFile.add(groupFile);
// Bloom
const composer = new EffectComposer(renderer);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
bloomPass.threshold = 0.1; bloomPass.strength = 0.4; bloomPass.radius = 0.5;
composer.addPass(bloomPass);
[sceneTree, sceneFile].forEach(s => {
s.add(new THREE.AmbientLight(0xffffff, 0.8));
const dl = new THREE.DirectionalLight(0xffffff, 1.5);
dl.position.set(10, 50, 20); s.add(dl);
});
// Backgrounds
const starGroup = new THREE.Group();
const starsGeo = new THREE.BufferGeometry();
const starPos = new Float32Array(3000 * 3);
for(let i=0; i<9000; i++) starPos[i] = (Math.random()-0.5)*1000;
starsGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({color:0xffffff, size:0.5, transparent:true, opacity:0.8});
starGroup.add(new THREE.Points(starsGeo, starMat));
// Dynamic Lasers
const orbitGroup = new THREE.Group();
const lasersV = new THREE.Group();
const lasersH = new THREE.Group();
// Vertical Lasers (Ring in XZ plane)
for(let i=0; i<10; i++) {
const r = 25 + Math.random() * 15;
const yOff = (Math.random()-0.5) * 80;
const pts = [];
const startAng = Math.random() * Math.PI * 2;
const length = Math.PI * 0.7; // 1/3 to 1/2 circle
for(let j=0; j<=32; j++) {
const t = startAng + (j/32)*length;
pts.push(new THREE.Vector3(Math.cos(t)*r, yOff, Math.sin(t)*r));
}
const curve = new THREE.CatmullRomCurve3(pts);
const tube = new THREE.TubeGeometry(curve, 32, 0.2, 4, false);
const mat = new THREE.MeshBasicMaterial({ color:0x0055ff, transparent:true, opacity:0.8, blending: THREE.AdditiveBlending });
lasersV.add(new THREE.Mesh(tube, mat));
}
// Horizontal Lasers (Ring in YZ plane for Tunnel F2)
for(let i=0; i<10; i++) {
const r = 25 + Math.random() * 15;
const xOff = (Math.random()-0.5) * 80;
const pts = [];
const startAng = Math.random() * Math.PI * 2;
const length = Math.PI * 0.7;
for(let j=0; j<=32; j++) {
const t = startAng + (j/32)*length;
pts.push(new THREE.Vector3(xOff, Math.cos(t)*r, Math.sin(t)*r));
}
const curve = new THREE.CatmullRomCurve3(pts);
const tube = new THREE.TubeGeometry(curve, 32, 0.2, 4, false);
const mat = new THREE.MeshBasicMaterial({ color:0x0055ff, transparent:true, opacity:0.8, blending: THREE.AdditiveBlending });
lasersH.add(new THREE.Mesh(tube, mat));
}
orbitGroup.add(lasersV);
orbitGroup.add(lasersH);
starGroup.add(orbitGroup);
sceneFile.add(starGroup);
const gridHelperTree = new THREE.GridHelper(500, 100, 0x004488, 0x001122);
gridHelperTree.position.y = -20; sceneTree.add(gridHelperTree);
const gridHelperFile = new THREE.GridHelper(500, 100, 0x00ffff, 0x111111);
gridHelperFile.position.y = -15; gridHelperFile.visible = false; sceneFile.add(gridHelperFile);
const reticleFile = new THREE.Group(); reticleFile.renderOrder = 10000;
const reticleTree = new THREE.Group(); sceneTree.add(reticleTree); reticleTree.visible = false;
function createSegmentedRing(rIn, rOut, count, speed, dir) {
const g = new THREE.Group();
const angle = (Math.PI * 2) / count; const gap = 0.2;
for(let i=0; i<count; i++) {
const s = i * angle + gap/2; const l = angle - gap;
const geo = new THREE.RingGeometry(rIn, rOut, 32, 1, s, l);
const mat = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true, opacity: 1.0, side: THREE.DoubleSide, depthTest: false });
g.add(new THREE.Mesh(geo, mat));
}
g.userData = { speed: speed, dir: dir }; return g;
}
function buildReticle(grp) {
grp.add(createSegmentedRing(1.4, 1.45, 3, 0.08, 1));
grp.add(createSegmentedRing(1.55, 1.6, 4, 0.05, -1));
grp.add(createSegmentedRing(1.7, 1.75, 6, 0.02, 1));
grp.userData = { layers: grp.children, currentScale: 0, targetScale: 0 };
}
buildReticle(reticleFile); buildReticle(reticleTree);
const hudC = document.getElementById('hud-canvas'); const hudX = hudC.getContext('2d');
function resizeRenderer() {
const w = window.innerWidth; const h = window.innerHeight;
renderer.setSize(w, h); composer.setSize(w, h);
hudC.width = w; hudC.height = h;
}
window.addEventListener('resize', resizeRenderer); resizeRenderer();
const magMesh = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 4.8), new THREE.MeshBasicMaterial({ map: null, transparent: true, opacity: 0, depthTest: false, side: THREE.DoubleSide }));
magMesh.renderOrder = 9999;
const infoMesh = new THREE.Mesh(new THREE.PlaneGeometry(12, 12), new THREE.MeshBasicMaterial({ map: null, transparent: true, opacity: 0, depthTest: false, side: THREE.DoubleSide }));
infoMesh.renderOrder = 9999;
const infoCvs = document.createElement('canvas'); infoCvs.width=512; infoCvs.height=512;
const infoCtx = infoCvs.getContext('2d');
const infoTex = new THREE.CanvasTexture(infoCvs);
infoMesh.material.map = infoTex;
infoMesh.userData = { fullLines: [], charCount: 0, ctx: infoCtx, tex: infoTex };
const arrowLineGeo = new THREE.BufferGeometry();
const arrowLine = new THREE.Line(arrowLineGeo, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false, opacity: 0.8, transparent: true }));
arrowLine.renderOrder = 9999;
const treeC = document.getElementById('tree-container');
treeC.addEventListener('pointerdown', e => {
closeAllMenus();
e.stopPropagation();
});
treeC.addEventListener('wheel', e => e.stopPropagation());
class TreeNode {
constructor(handle, parent = null) {
this.handle = handle; this.parent = parent; this.children = [];
this.mesh = null; this.depth = parent ? parent.depth + 1 : 0;
this.expanded = false;
}
}
function createTreeMesh(node) {
const grp = new THREE.Group();
const geo = new THREE.BoxGeometry(3, 3, 3);
const mat = new THREE.MeshPhongMaterial({ color: CONFIG.colFolderIdle, emissive: 0x000000, wireframe: false, transparent: true, opacity: 0.9 });
const mesh = new THREE.Mesh(geo, mat); grp.add(mesh);
const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: 0xffaa00, transparent:true, opacity:0.8 }));
mesh.add(edges);
const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=64; const ctx = cvs.getContext('2d');
ctx.font = 'bold 36px "Segoe UI"'; ctx.fillStyle='#fff'; ctx.textAlign='center';
ctx.shadowBlur=4; ctx.shadowColor='#00ffff'; ctx.fillText(node.handle.name, 128, 45);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs) }));
sprite.scale.set(6, 1.5, 1); sprite.position.y = -2.5; grp.add(sprite);
grp.userData = { isNode: true, node: node };
return grp;
}
function renderHtmlTree() {
treeC.innerHTML = ''; if(state.treeNodes.length > 0) treeC.appendChild(mkNode(state.treeNodes[0]));
}
function mkNode(node) {
const container = document.createElement('div');
const row = document.createElement('div'); row.className = 'tree-node'; row.style.paddingLeft = (node.depth * 20 + 10) + 'px';
const iconSpan = document.createElement('span'); iconSpan.className = 'tree-icon'; iconSpan.innerText = node.expanded ? '唐' : '刀';
iconSpan.onclick = (e) => { e.stopPropagation(); if(node.children.length > 0 || !node.expanded) { node.expanded = !node.expanded; updateTreeLayout(); } };
row.appendChild(iconSpan);
const nameSpan = document.createElement('span'); nameSpan.innerText = node.handle.name; row.appendChild(nameSpan);
row._node = node;
row.onclick = (e) => {
e.stopPropagation();
closeAllMenus();
selectNode(node);
};
row.oncontextmenu = (e) => { e.preventDefault(); e.stopPropagation(); selectNode(node); state.contextTarget = node.handle; document.getElementById('cm-expand').style.display='flex'; document.getElementById('cm-collapse').style.display='flex'; document.getElementById('cm-add-stock').style.display='flex'; showMenu(document.getElementById('ctx-obj'), e.clientX, e.clientY); };
container.appendChild(row);
if(node.expanded) node.children.forEach(c => { container.appendChild(mkNode(c)); });
return container;
}
function updateTreeLayout() {
groupTree.children.filter(c => c.userData.isLine).forEach(c => groupTree.remove(c));
if (!state.treeNodes[0] || !state.treeNodes[0].mesh) return;
const traverse = (node, levelIdx, levelCount) => {
if (!node.mesh) { node.mesh = createTreeMesh(node); groupTree.add(node.mesh); state.treeNodes.push(node); }
const z = -(node.depth * 50); let x = 0;
if (node.parent) {
const offset = (levelIdx - (levelCount-1)/2) * 20;
x = node.parent.mesh.position.x + offset;
const pts = [node.parent.mesh.position.clone(), new THREE.Vector3(x, 0, z)];
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x004466 }));
line.userData = { isLine: true }; groupTree.add(line);
}
node.mesh.position.set(x, 0, z); node.mesh.visible = true;
if (node.expanded && node.children.length > 0) node.children.forEach((c, i) => traverse(c, i, node.children.length));
else { const hide = (n) => { if(n.mesh) n.mesh.visible = false; n.children.forEach(hide); }; node.children.forEach(hide); }
};
traverse(state.treeNodes[0], 0, 1);
renderHtmlTree();
}
document.getElementById('btn-view-2d').onclick = () => { state.treeMode = '2D'; document.getElementById('btn-view-2d').classList.add('active'); document.getElementById('btn-view-3d').classList.remove('active'); document.getElementById('tree-container').style.display = 'block'; sfx.play('click'); };
document.getElementById('btn-view-3d').onclick = () => { state.treeMode = '3D'; document.getElementById('btn-view-3d').classList.add('active'); document.getElementById('btn-view-2d').classList.remove('active'); document.getElementById('tree-container').style.display = 'none'; groupTree.visible = true; updateTreeLayout(); sfx.play('click'); };
async function selectNode(node, forceExpand = false) {
if(!node) return;
state.activeNode = node; state.currentHandle = node.handle;
state.treeNodes.forEach(n => { if(n.mesh) { n.mesh.children[0].material.color.setHex(CONFIG.colFolderIdle); n.mesh.children[0].material.emissive.setHex(0x000000); } });
if(node.mesh) { node.mesh.children[0].material.color.setHex(CONFIG.colFolderActive); node.mesh.children[0].material.emissive.setHex(0x332200); reticleTree.visible = true; reticleTree.scale.set(3.0, 3.0, 3.0); reticleTree.position.copy(node.mesh.position); }
if(!node.expanded || forceExpand) {
if (!forceExpand && !node.expanded) {
let count = 0; try { for await (const e of node.handle.values()) { if(e.kind==='directory') count++; if(count>CONFIG.maxTreeChildren) break; } } catch(e){}
if (count > CONFIG.maxTreeChildren) { alert(`Warning: >${CONFIG.maxTreeChildren} subfolders.`); loadFilesForPane(node.handle); if(node.mesh) { state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z; state.targetTreeScrollX = node.mesh.position.x; } sfx.play('click'); return; }
}
const children = []; try { for await (const e of node.handle.values()) if (e.kind === 'directory') children.push(new TreeNode(e, node)); } catch(e){}
node.children = children; node.expanded = true; updateTreeLayout();
}
loadFilesForPane(node.handle);
if(node.mesh) { state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z; state.targetTreeScrollX = node.mesh.position.x; }
sfx.play('click');
}
async function reloadCurrentLevel() { if(state.activeNode) await selectNode(state.activeNode, true); }
async function loadFilesForPane(handle) {
state.isLoading = true; document.getElementById('loader').classList.add('active');
state.currentAllEntries = []; reticleFile.visible = false; state.selected = null; state.hovered = null; hideOverlays();
try { for await (const e of handle.values()) { state.currentAllEntries.push(e); if(state.currentAllEntries.length % 100 === 0) await new Promise(r=>setTimeout(r,0)); } } catch(e){}
document.getElementById('loader').classList.remove('active'); state.isLoading = false;
state.currentPage = 1; state.totalPages = Math.ceil(state.currentAllEntries.length / CONFIG.itemsPerPage) || 1;
renderCurrentPage();
}
function renderCurrentPage() {
document.getElementById('page-display').innerText = `${state.currentPage} / ${state.totalPages}`;
document.getElementById('btn-prev').classList.toggle('disabled', state.currentPage <= 1);
document.getElementById('btn-next').classList.toggle('disabled', state.currentPage >= state.totalPages);
state.currentRotX=0; state.targetRotX=0; state.currentRotY=0; state.targetRotY=0;
state.currentPosX=0; state.targetPosX=0; state.currentPosY=0; state.targetPosY=0; state.currentPosZ=0; state.targetPosZ=0;
const start = (state.currentPage - 1) * CONFIG.itemsPerPage; const end = start + CONFIG.itemsPerPage;
setMode(state.viewMode, state.currentAllEntries.slice(start, end));
}
document.getElementById('btn-prev').onclick = () => { if(state.currentPage > 1) { state.currentPage--; renderCurrentPage(); sfx.play('click'); } };
document.getElementById('btn-next').onclick = () => { if(state.currentPage < state.totalPages) { state.currentPage++; renderCurrentPage(); sfx.play('click'); } };
function createMonolith(handle, x, y, z, angle, index, mode) {
const isF = handle.kind === 'directory'; const col = isF ? CONFIG.colFolderIdle : CONFIG.colFile;
const geo = new THREE.BoxGeometry(2.4, 3.2, 0.1);
const mat = new THREE.MeshPhysicalMaterial({ color: col, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
if(mode === 'F1' || mode === 'F3') mesh.lookAt(x*2, y, z*2);
else if (mode === 'F2') mesh.lookAt(x, 0, 0);
else if (mode === 'F4') mesh.rotation.set(0, 0, 0);
const sizeStr = isF ? '<DIR>' : (Math.random()*5+1).toFixed(1)+' MB';
mesh.userData = { handle, isFolder:isF, isFileObj:true, baseColor: col, sizeStr: sizeStr };
const edges = new THREE.EdgesGeometry(geo);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 }));
mesh.add(line);
const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=256; const ctx = cvs.getContext('2d');
const ext = handle.name.split('.').pop().toUpperCase().slice(0,4);
ctx.font = `bold 100px "Segoe UI"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.lineWidth = 6; ctx.strokeStyle = '#000000'; ctx.strokeText(isF?'DIR':ext, 128, 128); ctx.fillStyle = '#ffffff'; ctx.fillText(isF?'DIR':ext, 128, 128);
const extSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cvs) }));
extSprite.scale.set(2, 2, 1); extSprite.position.z = (mode === 'F4') ? 0.6 : -1.5; mesh.add(extSprite);
const nCvs = document.createElement('canvas'); nCvs.width=512; nCvs.height=128; const nCtx = nCvs.getContext('2d');
nCtx.fillStyle = '#ffffff'; nCtx.font = 'bold 80px "Segoe UI"';
nCtx.shadowColor = 'black'; nCtx.shadowBlur = 6; nCtx.textAlign = 'center'; nCtx.fillText(handle.name, 256, 50);
const nameSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(nCvs), transparent: true }));
nameSprite.scale.set(4, 1, 1); nameSprite.position.set(0, -2.5, 0); mesh.add(nameSprite);
if (!isF) {
const n = handle.name.toLowerCase();
const loadT = (t) => { mesh.material = new THREE.MeshBasicMaterial({map:t, color:0xffffff}); mesh.userData.thumbTex = t; };
if(n.match(/\.(jpg|png|webp|gif)$/)) handle.getFile().then(f => new THREE.TextureLoader().load(URL.createObjectURL(f), loadT));
else if(n.match(/\.bmp$/)) handle.getFile().then(f => createImageBitmap(f)).then(bmp => loadT(new THREE.CanvasTexture(bmp)));
else if(n.match(/\.(mp4|webm)$/i)) handle.getFile().then(f => { const v = document.createElement('video'); v.src=URL.createObjectURL(f); v.muted=true; v.loop=true; v.play().then(()=>{ loadT(new THREE.VideoTexture(v)); }).catch(()=>{}); });
}
groupFile.add(mesh); state.fileObjects.push(mesh);
}
function setMode(mode, entries) {
state.viewMode = mode; document.getElementById('mode-info').innerText = `MODE: ${mode}`;
gridHelperFile.visible = false;
state.fileObjects.forEach(o => groupFile.remove(o)); state.fileObjects = [];
hideOverlays();
state.targetRotX=0; state.currentRotX=0; state.targetRotY=0; state.currentRotY=0;
state.targetPosX=0; state.currentPosX=0; state.targetPosY=0; state.currentPosY=0; state.targetPosZ=0; state.currentPosZ=0;
if(!entries) return;
const count = entries.length; const minRad = 22; const radius = Math.max(minRad, count * 1.5);
if (mode === 'F1') {
const spacing = 4.0; const circumference = 2 * Math.PI * radius;
entries.forEach((e, i) => {
const ringPos = i * ((Math.PI * 2) / Math.max(20, count/2));
const x = radius * Math.sin(ringPos); const z = radius * Math.cos(ringPos);
let y = (i % 2 === 0) ? 3.5 : -3.5;
createMonolith(e, x, y, z, ringPos, i, 'F1');
});
} else if (mode === 'F2') {
const itemsPerRing = 12; const spacingX = 10.0; const totalRings = Math.ceil(count / itemsPerRing);
entries.forEach((e, i) => {
const ring = Math.floor(i / itemsPerRing); const ang = (i % itemsPerRing) * ((Math.PI*2)/itemsPerRing);
const x = (ring * spacingX) - (totalRings * spacingX) / 2 - 10; // Shift back
createMonolith(e, x, radius * Math.sin(ang), radius * Math.cos(ang), ang, i, 'F2');
});
state.targetPosZ = -25; state.currentPosZ = -25;
} else if (mode === 'F3') {
state.targetPosY = 20;
entries.forEach((e, i) => { const ang = i * 0.3; createMonolith(e, radius*Math.sin(ang), -(i*2.0), radius*Math.cos(ang), ang, i, 'F3'); });
} else if (mode === 'F4') {
gridHelperFile.visible = true; const gridW = 5; const spX = 7.0; const spZ = 15.0;
// FIXED: Adjusted for lower altitude flyover view, closer to items for better visibility
state.targetRotX = -0.15; state.currentRotX = -0.15; // Shallow angle (flyover)
state.targetPosY = -10; state.currentPosY = -10; // Closer to items to make them bigger
state.targetPosZ = 0; state.currentPosZ = 0;
entries.forEach((e, i) => {
const col = i % gridW; const row = Math.floor(i / gridW);
const x = (col - gridW/2 + 0.5) * spX; const z = -row * spZ;
createMonolith(e, x, 0, z, 0, i, 'F4');
});
}
}
const splitter = document.getElementById('splitter'); const paneTree = document.getElementById('pane-tree'); const stockDiv = document.getElementById('stock');
let isStockDragging = false; let stockStartX, stockScrollLeft;
stockDiv.addEventListener('pointerdown', e => { if(e.target.closest('.stock-item')) return; isStockDragging=true; stockDiv.classList.add('active'); stockStartX = e.pageX - stockDiv.offsetLeft; stockScrollLeft = stockDiv.scrollLeft; e.stopPropagation(); });
splitter.addEventListener('pointerdown', e => { state.draggingSplit = true; splitter.classList.add('active'); e.preventDefault(); });
window.addEventListener('pointerup', async e => {
isStockDragging = false; stockDiv.classList.remove('active');
if (state.draggingSplit) { state.draggingSplit = false; splitter.classList.remove('active'); return; }
if (state.dragging) {
if(e.clientY > window.innerHeight - 180 && state.dragging.mesh) await moveToStock(state.dragging.mesh.userData.handle);
else if (!state.dragging.isMove && state.dragging.mesh) { if(state.dragging.mesh.userData.isFileObj) handleFileSelection(state.dragging.mesh); }
state.dragging = null;
}
document.body.style.cursor = 'default';
});
function handleFileSelection(mesh) {
state.fileObjects.forEach(o => { if(o.userData.isFolder) o.material.color.setHex(CONFIG.colFolderIdle); });
state.selected = mesh;
if(mesh.userData.isFolder) mesh.material.color.setHex(CONFIG.colFolderActive);
updateOverlays(mesh); sfx.play('lock');
reticleFile.userData.targetScale = 3.5; reticleFile.scale.set(5.5, 5.5, 1);
}
function hideOverlays() {
magMesh.opacity = 0; magMesh.visible = false; magMesh.removeFromParent();
infoMesh.opacity = 0; infoMesh.visible = false; infoMesh.removeFromParent();
arrowLine.visible = false; arrowLine.removeFromParent();
reticleFile.visible = false; reticleFile.removeFromParent();
reticleFile.userData.targetScale = 0;
}
function updateOverlays(target) {
target.add(reticleFile); target.add(infoMesh); target.add(arrowLine);
target.add(magMesh);
const isF = target.userData.isFolder;
infoMesh.userData.fullLines = [ `NAME: ${target.userData.handle.name}`, `KIND: ${target.userData.handle.kind.toUpperCase()}`, `DATE: ${new Date().toLocaleDateString()}`, `SIZE: ${target.userData.sizeStr}` ];
infoMesh.userData.charCount = 0; infoMesh.visible = true; infoMesh.material.opacity = 1;
if(isF) magMesh.material = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, depthTest: false });
else magMesh.material = new THREE.MeshBasicMaterial({ map: target.userData.thumbTex || null, side: THREE.DoubleSide, depthTest: false });
magMesh.visible = true; magMesh.material.opacity = 1;
let Z_FRONT = 15.0;
let magZ = 15.0;
if (state.viewMode === 'F2') {
magZ = 4.0;
Z_FRONT = 6.0;
} else if (state.viewMode === 'F4') {
magZ = 5.0;
} else {
magZ = 15.0;
}
reticleFile.position.set(0, 0, Z_FRONT + 0.1); reticleFile.rotation.set(0, 0, 0); reticleFile.scale.set(3.8, 3.8, 1); reticleFile.visible = true;
magMesh.position.set(0, 0, magZ); magMesh.rotation.set(0, 0, 0); magMesh.scale.set(1.25, 1.25, 1);
// Auto-flip Info position logic
const wp = new THREE.Vector3(); target.getWorldPosition(wp); wp.project(camFile);
let xOff = 8.0; if(wp.x > 0.4) xOff = -10.0;
let yOff = 0; if(wp.y < -0.6) yOff = 6.0; if(wp.y > 0.6) yOff = -6.0;
infoMesh.position.set(xOff, yOff, Z_FRONT); infoMesh.rotation.set(0, 0, 0); infoMesh.scale.set(1, 1, 1);
const yLine = -2.8; const thRight = 3.6; let pts = [];
if(xOff > 0) pts = [ new THREE.Vector3(thRight, 0, Z_FRONT), new THREE.Vector3(5.0, yLine+yOff, Z_FRONT), new THREE.Vector3(12.0, yLine+yOff, Z_FRONT) ];
else pts = [ new THREE.Vector3(-thRight, 0, Z_FRONT), new THREE.Vector3(-5.0, yLine+yOff, Z_FRONT), new THREE.Vector3(-12.0, yLine+yOff, Z_FRONT) ];
arrowLine.geometry.setFromPoints(pts); arrowLine.visible = true;
}
function getMouseForPane(e) {
const w = window.innerWidth; const h = window.innerHeight; const splitX = Math.max(1, w * (state.splitPercent / 100));
const mouse = new THREE.Vector2(); mouse.y = -(e.clientY / h) * 2 + 1;
if(e.clientX < splitX) { mouse.x = (e.clientX / splitX) * 2 - 1; return { vec: mouse, pane: 'tree' }; }
else { const fw = w - splitX; mouse.x = ((e.clientX - splitX) / fw) * 2 - 1; return { vec: mouse, pane: 'file' }; }
}
window.addEventListener('pointermove', e => {
if(isStockDragging) { e.preventDefault(); stockDiv.scrollLeft = stockScrollLeft - (e.pageX - stockDiv.offsetLeft)*1.5; return; }
if (state.draggingSplit) { const pct = (e.clientX/window.innerWidth)*100; if(pct>10 && pct<90){ state.splitPercent=pct; paneTree.style.flex=`0 0 ${pct}%`; resizeRenderer(); } return; }
const mData = getMouseForPane(e);
state.activePane = mData.pane;
state.mouse.copy(mData.vec);
if (e.buttons === 1 && !state.dragging) {
if(e.target.closest('#stock')) return;
const dx = e.clientX - state.lastMouse.x; const dy = e.clientY - state.lastMouse.y;
if(state.activePane === 'tree') { state.targetTreeScrollX -= dx*0.2; state.targetTreeScrollZ -= dy*0.5; }
else {
if(state.viewMode==='F1') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.1; }
else if(state.viewMode==='F2') { state.targetPosX += dx*0.05; state.targetRotX -= dy*0.005; }
else if(state.viewMode==='F3') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.2; }
else if(state.viewMode==='F4') { state.targetPosX += dx*0.1; state.targetPosZ += dy*0.1; }
}
} else if (!state.dragging) {
const raycaster = new THREE.Raycaster();
if (state.activePane === 'tree') {
raycaster.setFromCamera(state.mouse, camTree);
const hits = raycaster.intersectObjects(groupTree.children, true);
let hitNode = null; if(hits.length>0) { let obj=hits[0].object; while(obj&&!obj.userData.isNode)obj=obj.parent; hitNode=obj; }
if (hitNode && state.hovered !== hitNode) { sfx.play('hover'); state.hovered = hitNode; document.body.style.cursor='pointer'; }
else if (!hitNode) { state.hovered = null; document.body.style.cursor='default'; }
} else {
raycaster.setFromCamera(state.mouse, camFile);
const hits = raycaster.intersectObjects(state.fileObjects, true);
let hitObj = null; if(hits.length>0) { let obj=hits[0].object; while(obj&&!obj.userData.isFileObj)obj=obj.parent; hitObj=obj; }
if(hitObj) {
if (state.hovered !== hitObj) sfx.play('hover');
state.hovered = hitObj; document.body.style.cursor='pointer';
updateOverlays(hitObj);
if(!state.selected) { reticleFile.userData.targetScale = 3.5; reticleFile.scale.set(5.5, 5.5, 1); }
} else {
state.hovered = null; document.body.style.cursor='default';
if(!state.selected) hideOverlays(); else updateOverlays(state.selected);
}
}
}
state.lastMouse.set(e.clientX, e.clientY);
});
window.addEventListener('pointerdown', e => {
sfx.init(); if(e.target.closest('.ctx-menu')||e.target.closest('.stock-item')||e.target.closest('.stock-tab-item')||e.target.closest('.page-btn')||e.target.closest('#stock')||e.target.closest('#tree-header')) return;
closeAllMenus(); state.dragStart.set(e.clientX, e.clientY);
const mData = getMouseForPane(e);
if(mData.pane === 'tree') { if(state.hovered) selectNode(state.hovered.userData.node); }
if(mData.pane === 'file') {
if(state.hovered) state.dragging = { mesh: state.hovered, isMove: false };
else { state.selected = null; state.fileObjects.forEach(o=>{if(o.userData.isFolder)o.material.color.setHex(CONFIG.colFolderIdle);}); hideOverlays(); }
}
});
window.addEventListener('wheel', e => {
const mData = getMouseForPane(e);
if(mData.pane === 'tree') state.targetTreeScrollZ += e.deltaY*0.2;
else {
if(state.viewMode==='F1'||state.viewMode==='F3') state.targetRotY += e.deltaY*0.001;
else if(state.viewMode==='F2') state.targetRotX += e.deltaY*0.001;
else if(state.viewMode==='F4') state.targetPosZ -= e.deltaY*0.1;
if(state.viewMode==='F3'||state.viewMode==='F1') state.targetPosY += e.deltaY*0.05;
}
});
window.addEventListener('dblclick', e => {
const mData = getMouseForPane(e);
if(mData.pane === 'tree' && state.treeMode === '3D') {
const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mData.vec, camTree);
const hits = raycaster.intersectObjects(groupTree.children, true);
if(hits.length > 0) { let obj = hits[0].object; while(obj && !obj.userData.isNode) obj = obj.parent; if(obj) { selectNode(obj.userData.node); sfx.play('click'); } }
} else if(mData.pane === 'file' && state.selected && state.selected.userData.isFileObj) {
const h = state.selected.userData.handle;
if(h.kind==='directory') {
if(state.activeNode) { const child = state.activeNode.children.find(c => c.handle.name === h.name); if(child) selectNode(child); else selectNode(new TreeNode(h, state.activeNode)); }
} else openPreview(h);
}
});
window.addEventListener('keydown', e => { if(e.key.startsWith('F') && ['F1','F2','F3','F4'].includes(e.key)) { e.preventDefault(); setMode(e.key, state.currentAllEntries.slice((state.currentPage-1)*CONFIG.itemsPerPage, state.currentPage*CONFIG.itemsPerPage)); } });
function animate() {
requestAnimationFrame(animate);
state.treeScrollZ += (state.targetTreeScrollZ - state.treeScrollZ)*0.1; state.treeScrollX += (state.targetTreeScrollX - state.treeScrollX)*0.1;
camTree.position.z = state.treeScrollZ; camTree.position.x = state.treeScrollX; camTree.position.y = CONFIG.treeCamOffset.y; camTree.lookAt(state.treeScrollX, 0, state.treeScrollZ - 100);
state.currentRotY += (state.targetRotY - state.currentRotY)*0.1; state.currentPosY += (state.targetPosY - state.currentPosY)*0.1;
state.currentRotX += (state.targetRotX - state.currentRotX)*0.1; state.currentPosX += (state.targetPosX - state.currentPosX)*0.1; state.currentPosZ += (state.targetPosZ - state.currentPosZ)*0.1;
if(state.viewMode === 'F1' || state.viewMode === 'F3') { groupFile.rotation.y = state.currentRotY; groupFile.position.y = state.currentPosY; groupFile.rotation.x = 0; groupFile.position.x = 0; groupFile.position.z = 0; }
else if (state.viewMode === 'F2') { groupFile.rotation.x = state.currentRotX; groupFile.position.x = state.currentPosX; groupFile.rotation.y = 0; groupFile.position.y = 0; groupFile.position.z = 0; }
else if (state.viewMode === 'F4') { groupFile.rotation.set(state.currentRotX, 0, 0); groupFile.position.set(state.currentPosX, state.currentPosY, state.currentPosZ); }
starGroup.rotation.y = state.currentRotY * 0.2 + Date.now()*0.00005; starGroup.position.y = state.currentPosY * 0.5;
// Background lasers visibility/rotation switch
lasersV.visible = (state.viewMode !== 'F2');
lasersH.visible = (state.viewMode === 'F2');
if(state.viewMode === 'F2') lasersH.rotation.x += 0.02;
else lasersV.rotation.y += 0.02;
[reticleFile, reticleTree].forEach(grp => { if(grp.visible) grp.userData.layers.forEach(l => l.rotation.z += l.userData.speed * l.userData.dir); });
if(reticleFile.visible) {
const rs = reticleFile.scale.x; const ts = reticleFile.userData.targetScale || 0; const ns = rs + (ts - rs) * 0.15;
if(ts === 0 && ns < 0.1) reticleFile.visible = false; else reticleFile.scale.set(ns, ns, 1);
}
if((state.hovered || state.selected) && infoMesh.visible) {
magMesh.lookAt(camFile.position); infoMesh.lookAt(camFile.position);
if(infoMesh.userData.charCount < infoMesh.userData.fullLines.join('').length) {
infoMesh.userData.charCount += 2; const ctx = infoMesh.userData.ctx; ctx.clearRect(0,0,512,512);
ctx.font = "bold 40px 'Courier New'"; ctx.fillStyle = "#00ffff"; ctx.shadowColor="#00ffff"; ctx.shadowBlur=5;
let drawnCount = 0; let y = 50;
for(let line of infoMesh.userData.fullLines) {
let subLine = ""; if(drawnCount < infoMesh.userData.charCount) subLine = line.substring(0, infoMesh.userData.charCount - drawnCount);
if(subLine) ctx.fillText(subLine, 10, y); drawnCount += line.length; y += 60;
}
infoMesh.userData.tex.needsUpdate = true;
}
}
const width = window.innerWidth; const height = window.innerHeight;
const splitX = Math.max(1, width * (state.splitPercent / 100));
// Dynamic Aspect Ratio Update
camTree.aspect = splitX / height;
camTree.updateProjectionMatrix();
camFile.aspect = (width - splitX) / height;
camFile.updateProjectionMatrix();
renderer.setRenderTarget(composer.readBuffer);
renderer.clear();
// Render Tree Pane (Left)
renderer.setViewport(0, 0, splitX, height);
renderer.setScissor(0, 0, splitX, height);
renderer.setScissorTest(true);
renderer.render(sceneTree, camTree);
// Render File Pane (Right)
renderer.setViewport(splitX, 0, width-splitX, height);
renderer.setScissor(splitX, 0, width-splitX, height);
renderer.setScissorTest(true);
renderer.render(sceneFile, camFile);
renderer.setScissorTest(false);
renderer.setViewport(0, 0, width, height);
renderer.setRenderTarget(null);
composer.render();
}
animate();
const ctxObj = document.getElementById('ctx-obj'), ctxBg = document.getElementById('ctx-bg'), ctxTab = document.getElementById('ctx-tab'), ctxTreeBg = document.getElementById('ctx-tree-bg');
function closeAllMenus() { document.querySelectorAll('.ctx-menu').forEach(m => { if(m.style.display !== 'none' && !m.classList.contains('closing')) { m.classList.remove('opening'); m.classList.add('closing'); setTimeout(() => { m.style.display = 'none'; m.classList.remove('closing'); }, 400); } }); }
function showMenu(el, x, y) {
el.style.display='flex'; el.classList.remove('closing'); el.classList.add('opening');
let posX = x; let posY = y;
if(x + 200 > window.innerWidth) posX = x - 200;
if(y + 250 > window.innerHeight) posY = y - 250;
el.style.left=posX+'px'; el.style.top=posY+'px';
}
window.addEventListener('contextmenu', e => {
e.preventDefault();
if(e.target.closest('.stock-tab-item')) { const idx = e.target.closest('.stock-tab-item').dataset.idx; if (idx !== undefined) { state.tabContextTargetIdx = parseInt(idx); showMenu(ctxTab, e.clientX, e.clientY); } return; }
const mData = getMouseForPane(e);
if(mData.pane === 'tree') {
// FIXED: Perform Raycasting here to ensure accurate hit detection on right-click
let targetNode = null;
if(state.treeMode === '3D') {
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mData.vec, camTree);
const hits = raycaster.intersectObjects(groupTree.children, true);
if(hits.length > 0) {
let obj = hits[0].object;
while(obj && !obj.userData.isNode) obj = obj.parent;
if(obj) targetNode = obj;
}
} else if(state.hovered) { targetNode = state.hovered; }
if(targetNode) {
const node = targetNode.userData.node;
selectNode(node);
state.contextTarget = node.handle;
document.getElementById('cm-expand').style.display='flex';
document.getElementById('cm-collapse').style.display='flex';
document.getElementById('cm-add-stock').style.display = 'flex';
document.getElementById('cm-add-stock').innerText = 'Add Folder to Stock';
showMenu(ctxObj, e.clientX, e.clientY);
} else { showMenu(ctxTreeBg, e.clientX, e.clientY); }
return;
}
if(mData.pane === 'file') {
if(state.hovered) {
state.contextTarget = state.hovered.userData.handle; handleFileSelection(state.hovered);
document.getElementById('cm-expand').style.display='none'; document.getElementById('cm-collapse').style.display='none';
if(state.contextTarget.kind === 'directory') { document.getElementById('cm-add-stock').style.display = 'flex'; document.getElementById('cm-add-stock').innerText = 'Add Folder to Stock'; } else { document.getElementById('cm-add-stock').style.display = 'none'; }
showMenu(ctxObj, e.clientX, e.clientY);
} else { state.contextTarget = null; showMenu(ctxBg, e.clientX, e.clientY); }
}
});
document.getElementById('cm-expand').onclick = () => { if(state.activePane==='tree') { const node = state.treeNodes.find(n=>n.handle===state.contextTarget); if(node) selectNode(node, true); } closeAllMenus(); };
document.getElementById('cm-collapse').onclick = () => { if(state.activePane==='tree') { const node = state.treeNodes.find(n=>n.handle===state.contextTarget); if(node) { node.expanded=false; updateTreeLayout(); } } closeAllMenus(); };
document.getElementById('cm-open').onclick = () => { const t = state.contextTarget; if(t) { if(t.kind==='directory') loadFilesForPane(t); else openPreview(t); } closeAllMenus(); };
document.getElementById('cm-up').onclick = () => { if(state.activeNode && state.activeNode.parent) selectNode(state.activeNode.parent); closeAllMenus(); };
document.getElementById('cm-refresh').onclick = () => { reloadCurrentLevel(); closeAllMenus(); };
document.getElementById('cm-tree-up').onclick = () => { if(state.activeNode && state.activeNode.parent) selectNode(state.activeNode.parent); closeAllMenus(); };
document.getElementById('cm-tree-refresh').onclick = () => { reloadCurrentLevel(); closeAllMenus(); };
document.getElementById('cm-add-stock').onclick = async () => {
const t=state.contextTarget;
if(t&&t.kind==='directory') {
if(state.stockList.length === 0) {
try {
const stockDirHandle = await state.rootHandle.getDirectoryHandle('STOCK', {create: true});
state.stockList.push({handle: stockDirHandle, entries: []});
state.activeStockIdx = 0;
if(t.move) { await t.move(stockDirHandle); reloadCurrentLevel(); }
} catch(e) { console.error(e); }
}
else if(state.stockList.find(s=>s.handle.name===t.name)) { state.activeStockIdx=state.stockList.findIndex(s=>s.handle.name===t.name); }
else { state.stockList.push({handle:t, entries:[]}); state.activeStockIdx=state.stockList.length-1; }
renderStockTabs(); refreshStock(); document.getElementById('stock').classList.remove('closed');
}
closeAllMenus();
};
document.getElementById('cm-tab-hide').onclick=()=>{ document.getElementById('stock').classList.add('closed'); closeAllMenus(); };
document.getElementById('cm-tab-cancel').onclick=()=>{ state.stockList.splice(state.tabContextTargetIdx, 1); if(state.stockList.length===0){state.activeStockIdx=-1;document.getElementById('stock').classList.add('closed');} else state.activeStockIdx=Math.max(0, state.stockList.length-1); renderStockTabs(); refreshStock(); closeAllMenus(); };
document.getElementById('cm-new').onclick = async () => { closeAllMenus(); const n = await customPrompt("NEW FOLDER", "New Folder"); if(n) { await state.currentHandle.getDirectoryHandle(n, {create:true}); reloadCurrentLevel(); } };
document.getElementById('cm-rename').onclick = async () => { closeAllMenus(); const t = state.contextTarget; if(t) { const n = await customPrompt("RENAME", t.name); if(n && n!==t.name && t.move) { await t.move(n); reloadCurrentLevel(); } } };
document.getElementById('cm-paste').onclick = async () => { if(state.clipboard && state.clipboard.kind === 'file') { const f = await state.clipboard.getFile(); const h = await state.currentHandle.getFileHandle('Copy_'+f.name, {create:true}); const w = await h.createWritable(); await w.write(f); await w.close(); reloadCurrentLevel(); } closeAllMenus(); };
document.getElementById('cm-copy').onclick = () => { state.clipboard = state.contextTarget; closeAllMenus(); };
document.getElementById('cm-delete').onclick = async () => { closeAllMenus(); const t = state.contextTarget; if(t && confirm('Delete?')) { await state.currentHandle.removeEntry(t.name, {recursive:true}); reloadCurrentLevel(); } };
function renderStockTabs() { const c = document.getElementById('stock-tabs-container'); c.innerHTML = ''; if(state.stockList.length === 0) { c.innerHTML = '<div class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>'; return; } state.stockList.forEach((item, i) => { const div = document.createElement('div'); div.className = 'stock-tab-item'; if(i === state.activeStockIdx) div.classList.add('active'); div.innerText = item.handle.name.toUpperCase(); div.dataset.idx = i; div.onclick = () => { state.activeStockIdx = i; document.getElementById('stock').classList.remove('closed'); renderStockTabs(); refreshStock(); sfx.play('click'); }; c.appendChild(div); }); }
async function refreshStock() { const s = document.getElementById('stock'); s.innerHTML=''; if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>'; return; } const currentStock = state.stockList[state.activeStockIdx]; currentStock.entries = []; try { for await (const entry of currentStock.handle.values()) currentStock.entries.push(entry); if(currentStock.entries.length === 0) s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY FOLDER ]</div>'; else { for (let i=0; i<currentStock.entries.length; i++) { const h = currentStock.entries[i]; const d = document.createElement('div'); d.className = 'stock-item'; d.draggable = true; const thumb = document.createElement('div'); thumb.className = 'stock-thumb'; if(h.kind === 'directory') thumb.innerHTML = '<span style="font-size:40px">唐</span>'; else { const n = h.name.toLowerCase(); if(n.match(/\.(jpg|png|webp|gif)$/)) h.getFile().then(f => { const img=document.createElement('img'); img.src=URL.createObjectURL(f); img.style.maxWidth='100%'; img.style.maxHeight='100%'; thumb.appendChild(img); }); else thumb.innerHTML = '<span style="font-size:30px">塘</span>'; } d.appendChild(thumb); const lbl = document.createElement('div'); lbl.className = 'stock-label'; lbl.innerText = h.name; d.appendChild(lbl); d.ondragstart = e => { e.dataTransfer.setData('stockIndex', i); e.stopPropagation(); }; s.appendChild(d); } } } catch(e) { s.innerHTML = '<div style="color:red">ACCESS ERROR</div>'; } }
async function moveToStock(handle) { if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { alert("No Active Stock Tab!"); return; } const dest = state.stockList[state.activeStockIdx].handle; if(handle.move) { try { await handle.move(dest); refreshStock(); reloadCurrentLevel(); } catch(e){ sfx.play('error'); alert(e.message); } } }
async function moveFromStock(idx) { if(state.activeStockIdx<0) return; const h = state.stockList[state.activeStockIdx].entries[idx]; if(h && h.move) { try { await h.move(state.currentHandle); refreshStock(); reloadCurrentLevel(); } catch(e){ sfx.play('error'); alert(e.message); } } }
container.ondragover = e => e.preventDefault(); container.ondrop = async e => { e.preventDefault(); const idx = e.dataTransfer.getData('stockIndex'); if(idx) moveFromStock(parseInt(idx)); };
const dModal=document.getElementById('dialog'), dInput=document.getElementById('d-input'); let dResolve=null;
function customPrompt(title, val) { return new Promise(r => { document.getElementById('d-title').innerText=title; dInput.value=val; dResolve=r; dModal.classList.remove('closing'); dModal.classList.add('active','opening'); dInput.focus(); sfx.play('open'); }); }
function closeDialog(res){ dModal.classList.remove('opening'); dModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{dModal.classList.remove('active','closing'); if(dResolve) dResolve(res); dResolve=null;},500); }
document.getElementById('d-ok').onclick=()=>closeDialog(dInput.value); document.getElementById('d-cancel').onclick=()=>closeDialog(null); dInput.onkeydown=e=>{if(e.key==='Enter')closeDialog(dInput.value);if(e.key==='Escape')closeDialog(null);};
const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body'); let curPH=null;
async function openPreview(h) { curPH=h; pModal.classList.remove('closing'); pModal.classList.add('active','opening'); sfx.play('open'); document.getElementById('p-title').innerText=h.name; pBody.innerHTML='Loading...'; try { const f = await h.getFile(); const u = URL.createObjectURL(f); if(f.name.match(/\.(png|jpg|webp|gif)$/i)) pBody.innerHTML=`<img src="${u}" style="max-width:100%;max-height:100%">`; else if(f.name.match(/\.(mp4|webm)$/i)) pBody.innerHTML=`<video src="${u}" controls autoplay style="max-width:100%"></video>`; else { const t = await f.text(); pBody.innerHTML=`<textarea id="editor-area" class="hex-view">${t}</textarea>`; document.getElementById('p-save').style.display='block'; } } catch(e){ pBody.innerText="Error"; } }
document.getElementById('p-close').onclick=()=>{ pModal.classList.remove('opening'); pModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{pModal.classList.remove('active','closing');},500); };
document.getElementById('p-save').onclick=async()=>{ const ta=document.getElementById('editor-area'); if(curPH && ta) { try { const w=await curPH.createWritable(); await w.write(ta.value); await w.close(); alert("Saved"); } catch(e){alert("Error");} } };
document.getElementById('btn-select-root').onclick = async () => { sfx.init(); sfx.play('click'); try { const h = await window.showDirectoryPicker(); state.rootHandle = h; state.treeNodes = []; state.activeNode = null; groupTree.children.slice().forEach(c => { if(c.userData.isNode || c.userData.isLine) groupTree.remove(c); }); const root = new TreeNode(h); root.mesh = createTreeMesh(root); groupTree.add(root.mesh); state.treeNodes.push(root); await selectNode(root); updateTreeLayout(); } catch(e) { console.error(e); } };
</script>
</body>
</html>

コメント