ImageBoosterのコースエディタ
・ダウンロードされる方はこちら。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>High-Speed Course Editor Pro</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
/* Menu Bar */
#menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e; }
.menu-item { padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc; }
.menu-item:hover { background: #3e3e3e; color: #fff; }
.dropdown { display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
.menu-item:hover .dropdown { display: block; }
.dropdown-item { padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px; }
.dropdown-item:hover { background: #094771; color: #fff; }
.separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }
/* Layout */
#container { display: flex; height: calc(100vh - 30px); }
#canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #1e1e1e; }
/* Right Panel */
#properties-panel { width: 260px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto; }
.prop-group { margin-bottom: 20px; }
.prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
input[type="text"], input[type="number"], input[type="color"], select {
width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 8px; font-size: 12px; border-radius: 2px; text-align: right;
}
select { text-align: left; cursor: pointer; }
input:focus, select:focus { border-color: #007fd4; outline: none; }
/* Status / UI Overlays */
#status-bar { position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8); }
#info-overlay { position: absolute; top: 10px; left: 10px; pointer-events: none; }
#mode-display { background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block; }
#height-display { background: rgba(0,0,0,0.5); color: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table; }
/* Palette UI */
#palette-box { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
.palette-swatch { width: 20px; height: 20px; border: 1px solid #555; cursor: pointer; box-sizing: border-box; }
.palette-swatch:hover { border-color: #fff; transform: scale(1.1); }
</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="menubar">
<div class="menu-item">File
<div class="dropdown">
<div class="dropdown-item" id="menu-new">New Course</div>
<div class="dropdown-item" id="menu-open">Open JSON</div>
<div class="dropdown-item" id="menu-save">Save JSON</div>
</div>
</div>
<div class="menu-item">Edit
<div class="dropdown">
<div class="dropdown-item" id="tool-select">Select</div>
<div class="dropdown-item" id="tool-add">Add</div>
<div class="dropdown-item" id="tool-erase">Erase (Del)</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-move">Move</div>
<div class="dropdown-item" id="tool-rotate">Rotate (Scenery only)</div>
<div class="dropdown-item" id="tool-scale">Scale (Scenery only)</div>
</div>
</div>
<div class="menu-item">View
<div class="dropdown">
<div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
<div class="dropdown-item" onclick="viewCam('top')">Top (F2)</div>
<div class="dropdown-item" onclick="viewCam('front')">Front (F3)</div>
</div>
</div>
</div>
<div id="container">
<div id="canvas-container" tabindex="0">
<div id="info-overlay">
<div id="mode-display">Select</div>
<div id="height-display">Height: 0</div>
</div>
<div id="status-bar">Ready | Snap Grid: 10 | Move: Hold Click + Drag | Lock Axis: Hold X, Y, or Z</div>
</div>
<div id="properties-panel">
<h3 id="prop-header" style="color:#4fc1ff; margin-top:0; border-bottom:1px solid #444; padding-bottom:5px;">Properties</h3>
<div id="prop-add-settings" style="display:none;">
<div class="prop-group">
<span class="prop-label">Item to Add</span>
<select id="add-item-type">
<option value="node">Track Node (Spline Point)</option>
<option value="scenery">Scenery Block</option>
</select>
</div>
<div id="add-scenery-options" style="display:none;">
<div class="prop-group">
<span class="prop-label">Color</span>
<div id="palette-box"></div>
<input type="color" id="brush-color" value="#888888">
</div>
</div>
</div>
<div id="prop-content" style="display:none;">
<div class="prop-group">
<span class="prop-label">Type</span>
<input type="text" id="prop-type" disabled style="color:#888;">
</div>
<div class="prop-group">
<span class="prop-label">Position (X, Y, Z)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-x" step="10">
<input type="number" id="prop-y" step="10">
<input type="number" id="prop-z" step="10">
</div>
</div>
<div id="prop-node-group" class="prop-group">
<span class="prop-label">Tunnel Bank / Roll (Deg)</span>
<input type="number" id="prop-roll" step="15">
</div>
<div id="prop-scenery-group">
<div class="prop-group">
<span class="prop-label">Rotation Y (Deg)</span>
<input type="number" id="prop-ry" step="15">
</div>
<div class="prop-group">
<span class="prop-label">Scale (X, Y, Z)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-sx" step="1">
<input type="number" id="prop-sy" step="1">
<input type="number" id="prop-sz" step="1">
</div>
</div>
<div class="prop-group">
<span class="prop-label">Color</span>
<input type="color" id="prop-color">
</div>
</div>
</div>
<div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">
No item selected.
</div>
</div>
</div>
<input type="file" id="file-input" style="display: none;" accept=".json">
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- State Variables ---
let scene, camera, renderer, controls;
let raycaster, mouse;
let currentTool = 'select';
let gridSize = 10;
let baseHeight = 0;
// Data Arrays
let trackNodes = []; // mesh, roll
let sceneryBlocks = []; // mesh
let trackSplineMesh = null;
let selectedItem = null;
let selectedItemType = null; // 'node' or 'scenery'
let addItemType = 'node';
// Palette
let palette = ['#888888', '#ff3333', '#33ff33', '#3333ff', '#ffff33', '#00ffff', '#ff00ff', '#111111'];
// Visual Helpers
let gridGroup = new THREE.Group();
let planeGround;
let cursorTarget; // Red (Add)
let cursorPoint; // White (Hover)
let cursorSelect; // Yellow (Select)
// Interaction State
let isDragging = false;
let dragStartMouse = new THREE.Vector2();
let dragStartVal = new THREE.Vector3();
let startCursorPos = new THREE.Vector3();
// Axis Lock State
let axisLock = { x: false, y: false, z: false };
// --- Init ---
function init() {
const container = document.getElementById('canvas-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1e1e);
scene.fog = new THREE.Fog(0x1e1e1e, 200, 1500);
camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 1, 5000);
camera.position.set(100, 100, 100);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(100, 300, 200);
scene.add(sun);
scene.add(gridGroup);
initCursors();
updateEnvironment();
renderPalette();
const pgGeo = new THREE.PlaneGeometry(10000, 10000);
const pgMat = new THREE.MeshBasicMaterial({ visible: false });
planeGround = new THREE.Mesh(pgGeo, pgMat);
planeGround.rotation.x = -Math.PI / 2;
scene.add(planeGround);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
window.addEventListener('resize', onResize);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
const cvs = renderer.domElement;
cvs.addEventListener('pointermove', onPointerMove);
cvs.addEventListener('pointerdown', onPointerDown);
cvs.addEventListener('pointerup', onPointerUp);
cvs.addEventListener('contextmenu', (e) => { e.preventDefault(); });
setupUI();
setTool('select');
// Initial Nodes
createNode(new THREE.Vector3(0, 50, 0), 0);
createNode(new THREE.Vector3(0, 50, -200), 0);
createNode(new THREE.Vector3(200, 50, -100), 45);
rebuildTrackCurve();
animate();
}
function initCursors() {
const tGeo = new THREE.PlaneGeometry(10, 10);
const tMat = new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 });
cursorTarget = new THREE.Mesh(tGeo, tMat);
cursorTarget.rotation.x = -Math.PI / 2;
scene.add(cursorTarget);
const pGeo = new THREE.BoxGeometry(10, 10, 10);
const pEdges = new THREE.EdgesGeometry(pGeo);
cursorPoint = new THREE.LineSegments(pEdges, new THREE.LineBasicMaterial({ color: 0xffffff, opacity: 0.8, transparent: true }));
scene.add(cursorPoint);
const sGeo = new THREE.BoxGeometry(10, 10, 10);
const sEdges = new THREE.EdgesGeometry(sGeo);
cursorSelect = new THREE.LineSegments(sEdges, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false }));
cursorSelect.visible = false;
scene.add(cursorSelect);
}
function updateEnvironment() {
while(gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]);
const floorGrid = new THREE.GridHelper(1000, 100, 0x444444, 0x2a2a2a);
gridGroup.add(floorGrid);
}
function renderPalette() {
const box = document.getElementById('palette-box');
box.innerHTML = '';
palette.forEach(color => {
const el = document.createElement('div');
el.className = 'palette-swatch';
el.style.backgroundColor = color;
el.onclick = () => { document.getElementById('brush-color').value = color; };
box.appendChild(el);
});
}
function animate() {
requestAnimationFrame(animate);
controls.update();
if(cursorTarget) {
cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(Date.now() * 0.005 * 8);
}
renderer.render(scene, camera);
}
function snapToGrid(val) {
return Math.round(val / gridSize) * gridSize;
}
// --- Core Builders ---
function createNode(pos, roll) {
const geo = new THREE.SphereGeometry(6, 16, 16);
const mat = new THREE.MeshPhongMaterial({ color: 0x00ff00, emissive: 0x004400 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
mesh.userData = { type: 'node', roll: roll };
scene.add(mesh);
trackNodes.push(mesh);
rebuildTrackCurve();
return mesh;
}
function createScenery(pos, scaleVec, colorHex, rotY) {
const geo = new THREE.BoxGeometry(gridSize, gridSize, gridSize);
const mat = new THREE.MeshStandardMaterial({ color: colorHex });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos);
mesh.scale.copy(scaleVec);
mesh.rotation.y = rotY;
mesh.userData = { type: 'scenery' };
scene.add(mesh);
sceneryBlocks.push(mesh);
return mesh;
}
function rebuildTrackCurve() {
if (trackSplineMesh) scene.remove(trackSplineMesh);
if (trackNodes.length < 3) return;
const points = trackNodes.map(n => n.position);
const curve = new THREE.CatmullRomCurve3(points, true); // true = loop
const tubeGeo = new THREE.TubeGeometry(curve, trackNodes.length * 20, 12, 8, true);
const tubeMat = new THREE.MeshBasicMaterial({ color: 0x00ffff, wireframe: true, transparent: true, opacity: 0.2 });
trackSplineMesh = new THREE.Mesh(tubeGeo, tubeMat);
scene.add(trackSplineMesh);
const lineGeo = new THREE.BufferGeometry().setFromPoints(curve.getPoints(trackNodes.length * 20));
const lineMat = new THREE.LineBasicMaterial({ color: 0xffff00 });
trackSplineMesh.add(new THREE.Line(lineGeo, lineMat));
}
// --- Interaction Logic ---
function onPointerMove(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;
const isLocked = axisLock.x || axisLock.y || axisLock.z;
// Handle Dragging selected object
if (isDragging && selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) {
const deltaX = e.clientX - dragStartMouse.x;
const deltaY = e.clientY - dragStartMouse.y;
if (currentTool === 'move') {
if (isLocked) {
const mag = (deltaX - deltaY) * 0.5;
if(axisLock.x) selectedItem.position.x = snapToGrid(dragStartVal.x + mag);
if(axisLock.y) selectedItem.position.y = snapToGrid(dragStartVal.y + mag);
if(axisLock.z) selectedItem.position.z = snapToGrid(dragStartVal.z + mag);
} else {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(planeGround);
if(intersects.length > 0) {
selectedItem.position.x = snapToGrid(intersects[0].point.x);
selectedItem.position.z = snapToGrid(intersects[0].point.z);
}
}
if(selectedItem.userData.type === 'node') rebuildTrackCurve();
}
else if (currentTool === 'rotate' && selectedItem.userData.type === 'scenery') {
const snapRad = Math.PI / 4;
selectedItem.rotation.y = Math.round((dragStartVal.y + deltaX * 0.02) / snapRad) * snapRad;
}
else if (currentTool === 'scale' && selectedItem.userData.type === 'scenery') {
const mag = -deltaY * 0.05;
let sVec = dragStartVal.clone();
if(isLocked) {
if(axisLock.x) sVec.x = Math.max(1, Math.round(dragStartVal.x + mag));
if(axisLock.y) sVec.y = Math.max(1, Math.round(dragStartVal.y + mag));
if(axisLock.z) sVec.z = Math.max(1, Math.round(dragStartVal.z + mag));
} else {
const s = Math.max(1, Math.round(dragStartVal.x + mag));
sVec.set(s,s,s);
}
selectedItem.scale.copy(sVec);
// Adjust position so it scales from the bottom if needed (optional, keeping it simple center-scaled for now)
}
updateSelectionBox();
updateUIFromSelection();
return;
}
// Dragging empty cursor (for Add tool)
if (isDragging && !selectedItem && currentTool === 'add' && isLocked) {
const deltaX = e.clientX - dragStartMouse.x;
const deltaY = e.clientY - dragStartMouse.y;
const mag = (deltaX - deltaY) * 0.5;
if(axisLock.x) cursorTarget.position.x = snapToGrid(startCursorPos.x + mag);
if(axisLock.y) cursorTarget.position.y = snapToGrid(startCursorPos.y + mag);
if(axisLock.z) cursorTarget.position.z = snapToGrid(startCursorPos.z + mag);
document.getElementById('height-display').textContent = `Height: ${cursorTarget.position.y}`;
return;
}
// Hover Logic
if (!isDragging) {
raycaster.setFromCamera(mouse, camera);
if (currentTool === 'add') {
const intersects = raycaster.intersectObject(planeGround);
if (intersects.length > 0) {
const p = intersects[0].point;
cursorTarget.position.set(snapToGrid(p.x), baseHeight, snapToGrid(p.z));
}
} else {
const allMeshes = [...trackNodes, ...sceneryBlocks];
const intersects = raycaster.intersectObjects(allMeshes);
if (intersects.length > 0) {
const hit = intersects[0].object;
cursorPoint.position.copy(hit.position);
if(hit.userData.type === 'node') cursorPoint.scale.set(1.5,1.5,1.5);
else cursorPoint.scale.copy(hit.scale);
cursorPoint.visible = true;
} else {
const pIntersects = raycaster.intersectObject(planeGround);
if (pIntersects.length > 0) {
cursorPoint.scale.set(1,1,1);
cursorPoint.position.set(snapToGrid(pIntersects[0].point.x), baseHeight, snapToGrid(pIntersects[0].point.z));
cursorPoint.visible = true;
}
}
}
}
}
function onPointerDown(e) {
if (e.button !== 0) return;
const isLocked = axisLock.x || axisLock.y || axisLock.z;
if (isLocked) {
isDragging = true;
dragStartMouse.set(e.clientX, e.clientY);
controls.enabled = false;
if(selectedItem && ['move', 'rotate', 'scale'].includes(currentTool)) {
if (currentTool === 'move') dragStartVal.copy(selectedItem.position);
if (currentTool === 'rotate') dragStartVal.set(0, selectedItem.rotation.y, 0);
if (currentTool === 'scale') dragStartVal.copy(selectedItem.scale);
} else {
startCursorPos.copy(cursorTarget.position);
}
return;
}
raycaster.setFromCamera(mouse, camera);
const allMeshes = [...trackNodes, ...sceneryBlocks];
const intersects = raycaster.intersectObjects(allMeshes);
const hitItem = intersects.length > 0 ? intersects[0].object : null;
if (currentTool === 'select') {
selectItem(hitItem);
}
else if (currentTool === 'add') {
if(addItemType === 'node') {
createNode(cursorTarget.position.clone(), 0);
} else {
const col = document.getElementById('brush-color').value;
createScenery(cursorTarget.position.clone(), new THREE.Vector3(1,1,1), col, 0);
}
}
else if (currentTool === 'erase') {
if(hitItem) deleteItem(hitItem);
}
else if (['move', 'rotate', 'scale'].includes(currentTool)) {
if (hitItem) {
selectItem(hitItem);
isDragging = true;
controls.enabled = false;
dragStartMouse.set(e.clientX, e.clientY);
if (currentTool === 'move') dragStartVal.copy(hitItem.position);
if (currentTool === 'rotate') dragStartVal.set(0, hitItem.rotation.y, 0);
if (currentTool === 'scale') dragStartVal.copy(hitItem.scale);
} else {
selectItem(null);
}
}
}
function onPointerUp() {
isDragging = false;
controls.enabled = true;
}
function onKeyDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if(e.key.toLowerCase() === 'x') axisLock.x = true;
if(e.key.toLowerCase() === 'y') axisLock.y = true;
if(e.key.toLowerCase() === 'z') axisLock.z = true;
if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
if (e.key === 'F2') { e.preventDefault(); viewCam('top'); }
if (e.key === 'F3') { e.preventDefault(); viewCam('front'); }
if (e.key === 'ArrowUp') {
e.preventDefault();
if (['move','select'].includes(currentTool) && selectedItem) {
selectedItem.position.y += gridSize;
if(selectedItem.userData.type === 'node') rebuildTrackCurve();
updateUIFromSelection();
updateSelectionBox();
} else {
baseHeight += gridSize;
document.getElementById('height-display').textContent = `Height: ${baseHeight}`;
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (['move','select'].includes(currentTool) && selectedItem) {
selectedItem.position.y -= gridSize;
if(selectedItem.userData.type === 'node') rebuildTrackCurve();
updateUIFromSelection();
updateSelectionBox();
} else {
baseHeight -= gridSize;
document.getElementById('height-display').textContent = `Height: ${baseHeight}`;
}
}
if (e.key === 'Delete') { if (selectedItem) deleteItem(selectedItem); }
}
function onKeyUp(e) {
if(e.key.toLowerCase() === 'x') axisLock.x = false;
if(e.key.toLowerCase() === 'y') axisLock.y = false;
if(e.key.toLowerCase() === 'z') axisLock.z = false;
}
// --- Selection & Editing ---
function deleteItem(mesh) {
scene.remove(mesh);
if(mesh.userData.type === 'node') {
trackNodes = trackNodes.filter(v => v !== mesh);
rebuildTrackCurve();
} else {
sceneryBlocks = sceneryBlocks.filter(v => v !== mesh);
}
if (selectedItem === mesh) selectItem(null);
}
function selectItem(mesh) {
selectedItem = mesh;
if (mesh) {
selectedItemType = mesh.userData.type;
if(currentTool !== 'add') cursorSelect.visible = true;
updateSelectionBox();
updateUIFromSelection();
} else {
selectedItemType = null;
cursorSelect.visible = false;
if (currentTool !== 'add') {
document.getElementById('prop-content').style.display = 'none';
document.getElementById('prop-empty').style.display = 'block';
}
}
}
function updateSelectionBox() {
if(!selectedItem) return;
cursorSelect.position.copy(selectedItem.position);
cursorSelect.rotation.copy(selectedItem.rotation);
if(selectedItem.userData.type === 'node') cursorSelect.scale.set(1.5,1.5,1.5);
else cursorSelect.scale.copy(selectedItem.scale).multiplyScalar(1.05);
}
// --- UI Binding ---
window.setTool = (tool) => {
currentTool = tool;
const names = { 'select': 'Select', 'add': 'Add', 'erase': 'Erase', 'move': 'Move', 'rotate': 'Rotate', 'scale': 'Scale' };
document.getElementById('mode-display').innerText = names[tool];
if(tool === 'add') {
cursorTarget.visible = true; cursorPoint.visible = false; cursorSelect.visible = false;
document.getElementById('prop-header').innerText = "Add Settings";
document.getElementById('prop-add-settings').style.display = 'block';
document.getElementById('prop-content').style.display = 'none';
document.getElementById('prop-empty').style.display = 'none';
} else {
cursorTarget.visible = false; cursorPoint.visible = true;
if(selectedItem) cursorSelect.visible = true;
document.getElementById('prop-header').innerText = "Properties";
document.getElementById('prop-add-settings').style.display = 'none';
if(selectedItem) updateUIFromSelection();
else {
document.getElementById('prop-content').style.display = 'none';
document.getElementById('prop-empty').style.display = 'block';
}
}
};
window.viewCam = (dir) => {
const dist = 300;
camera.up.set(0, 1, 0);
switch(dir) {
case 'default': camera.position.set(dist, dist, dist); break;
case 'front': camera.position.set(0, 0, dist); break;
case 'top': camera.position.set(0, dist, 0); break;
}
camera.lookAt(0,0,0); controls.target.set(0,0,0); controls.update();
};
function setupUI() {
// Tools
['select', 'add', 'erase', 'move', 'rotate', 'scale'].forEach(t => {
document.getElementById(`tool-${t}`).onclick = () => setTool(t);
});
// Add Options
document.getElementById('add-item-type').addEventListener('change', (e) => {
addItemType = e.target.value;
document.getElementById('add-scenery-options').style.display = addItemType === 'scenery' ? 'block' : 'none';
if(addItemType === 'node') cursorTarget.scale.set(1,1,1);
else cursorTarget.scale.set(1,1,1);
});
// Property Bindings
const bindProp = (id, objKey, axis, isDeg) => {
document.getElementById(id).addEventListener('input', (e) => {
if(!selectedItem) return;
let val = parseFloat(e.target.value);
if(isDeg) val = THREE.MathUtils.degToRad(val);
if(objKey === 'roll') {
selectedItem.userData.roll = val;
rebuildTrackCurve();
} else if(axis) {
selectedItem[objKey][axis] = val;
if(objKey === 'position' && selectedItem.userData.type === 'node') rebuildTrackCurve();
} else if(objKey === 'color') {
selectedItem.material.color.set(e.target.value);
}
updateSelectionBox();
});
};
bindProp('prop-x', 'position', 'x'); bindProp('prop-y', 'position', 'y'); bindProp('prop-z', 'position', 'z');
bindProp('prop-sx', 'scale', 'x'); bindProp('prop-sy', 'scale', 'y'); bindProp('prop-sz', 'scale', 'z');
bindProp('prop-ry', 'rotation', 'y', true);
bindProp('prop-roll', 'roll', null, false); // Keep roll in degrees in UserData
bindProp('prop-color', 'color');
// Save/Load
document.getElementById('menu-new').onclick = () => {
if(confirm("Clear all course data?")) {
trackNodes.forEach(n => scene.remove(n));
sceneryBlocks.forEach(s => scene.remove(s));
trackNodes = []; sceneryBlocks = [];
rebuildTrackCurve(); selectItem(null);
}
};
document.getElementById('menu-save').onclick = () => {
const data = {
track: trackNodes.map(n => ({ x: n.position.x, y: n.position.y, z: n.position.z, roll: n.userData.roll })),
scenery: sceneryBlocks.map(s => ({
x: s.position.x, y: s.position.y, z: s.position.z,
sx: s.scale.x, sy: s.scale.y, sz: s.scale.z,
ry: s.rotation.y, color: s.material.color.getHex()
}))
};
const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob); link.download = 'course.json'; link.click();
};
document.getElementById('menu-open').onclick = () => document.getElementById('file-input').click();
document.getElementById('file-input').onchange = (e) => {
const f = e.target.files[0]; if(!f) return;
const r = new FileReader();
r.onload = (ev) => {
const data = JSON.parse(ev.target.result);
trackNodes.forEach(n => scene.remove(n)); sceneryBlocks.forEach(s => scene.remove(s));
trackNodes = []; sceneryBlocks = [];
data.track.forEach(n => createNode(new THREE.Vector3(n.x, n.y, n.z), n.roll));
data.scenery.forEach(s => createScenery(new THREE.Vector3(s.x, s.y, s.z), new THREE.Vector3(s.sx, s.sy, s.sz), s.color, s.ry));
selectItem(null);
};
r.readAsText(f);
};
}
function updateUIFromSelection() {
if(!selectedItem) return;
if(currentTool === 'add') return;
document.getElementById('prop-content').style.display = 'block';
document.getElementById('prop-empty').style.display = 'none';
const isNode = selectedItem.userData.type === 'node';
document.getElementById('prop-type').value = isNode ? 'Track Node' : 'Scenery Block';
document.getElementById('prop-x').value = selectedItem.position.x;
document.getElementById('prop-y').value = selectedItem.position.y;
document.getElementById('prop-z').value = selectedItem.position.z;
document.getElementById('prop-node-group').style.display = isNode ? 'block' : 'none';
document.getElementById('prop-scenery-group').style.display = isNode ? 'none' : 'block';
if(isNode) {
document.getElementById('prop-roll').value = selectedItem.userData.roll;
} else {
document.getElementById('prop-sx').value = selectedItem.scale.x;
document.getElementById('prop-sy').value = selectedItem.scale.y;
document.getElementById('prop-sz').value = selectedItem.scale.z;
document.getElementById('prop-ry').value = Math.round(THREE.MathUtils.radToDeg(selectedItem.rotation.y));
document.getElementById('prop-color').value = '#' + selectedItem.material.color.getHexString();
}
}
function onResize() {
const c = document.getElementById('canvas-container');
camera.aspect = c.clientWidth / c.clientHeight; camera.updateProjectionMatrix();
renderer.setSize(c.clientWidth, c.clientHeight);
}
init();
</script>
</body>
</html>

コメント