Update app.py
Browse files
app.py
CHANGED
|
@@ -4,71 +4,74 @@ import time
|
|
| 4 |
from datetime import datetime
|
| 5 |
from uuid import uuid4
|
| 6 |
|
| 7 |
-
from flask import Flask, render_template_string, request, jsonify
|
| 8 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 9 |
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
|
| 10 |
from dotenv import load_dotenv
|
|
|
|
| 11 |
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
app = Flask(__name__)
|
| 15 |
app.secret_key = 'level_designer_secret_key_zomboid_5678'
|
|
|
|
|
|
|
| 16 |
|
| 17 |
REPO_ID = "Kgshop/Testai"
|
| 18 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 19 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 20 |
|
| 21 |
-
def
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return False
|
| 24 |
try:
|
| 25 |
-
api = HfApi()
|
| 26 |
api.upload_file(
|
| 27 |
path_or_fileobj=local_path,
|
| 28 |
-
path_in_repo=
|
| 29 |
repo_id=REPO_ID,
|
| 30 |
repo_type="dataset",
|
| 31 |
-
|
| 32 |
-
commit_message=f"Save PZ project {project_name} at {datetime.now()}"
|
| 33 |
)
|
| 34 |
return True
|
| 35 |
except Exception as e:
|
| 36 |
-
print(f"Error uploading
|
| 37 |
return False
|
| 38 |
|
| 39 |
-
def
|
| 40 |
-
|
| 41 |
-
if not
|
| 42 |
return None
|
| 43 |
try:
|
| 44 |
local_path = hf_hub_download(
|
| 45 |
repo_id=REPO_ID,
|
| 46 |
-
filename=
|
| 47 |
repo_type="dataset",
|
| 48 |
-
token=
|
| 49 |
local_dir=".",
|
| 50 |
local_dir_use_symlinks=False,
|
| 51 |
force_download=True
|
| 52 |
)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
if os.path.exists(local_path):
|
| 56 |
-
os.remove(local_path)
|
| 57 |
-
return data
|
| 58 |
-
except (HfHubHTTPError, RepositoryNotFoundError) as e:
|
| 59 |
return None
|
| 60 |
-
except Exception
|
| 61 |
return None
|
| 62 |
|
| 63 |
-
def
|
| 64 |
-
|
| 65 |
-
if not
|
| 66 |
return []
|
| 67 |
try:
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('pz_projects/') and f.endswith('.json')]
|
| 71 |
-
return sorted(project_files)
|
| 72 |
except Exception:
|
| 73 |
return []
|
| 74 |
|
|
@@ -84,44 +87,42 @@ EDITOR_TEMPLATE = '''
|
|
| 84 |
canvas { display: block; }
|
| 85 |
#ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; display: flex; }
|
| 86 |
#ui-panel {
|
| 87 |
-
background: rgba(10, 10, 10, 0.
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
border-right: 1px solid #444;
|
| 91 |
-
width: 320px;
|
| 92 |
-
height: 100%;
|
| 93 |
-
overflow-y: auto;
|
| 94 |
-
box-sizing: border-box;
|
| 95 |
-
transition: transform 0.3s ease-in-out;
|
| 96 |
-
transform: translateX(0);
|
| 97 |
-
pointer-events: auto;
|
| 98 |
}
|
| 99 |
.ui-group { margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }
|
| 100 |
.ui-group:last-child { border-bottom: none; }
|
| 101 |
h3 { margin-top: 0; font-size: 1.2em; color: #00aaff; border-bottom: 1px solid #00aaff; padding-bottom: 5px; margin-bottom: 10px; }
|
| 102 |
label { display: block; margin-bottom: 5px; font-size: 0.9em; }
|
| 103 |
-
input[type="text"], select {
|
| 104 |
-
width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; margin-bottom:
|
| 105 |
}
|
|
|
|
| 106 |
button {
|
| 107 |
-
width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold;
|
|
|
|
| 108 |
}
|
| 109 |
button:hover { background: #0099ff; }
|
| 110 |
button.play-button { background: #22aa22; }
|
| 111 |
button.play-button:hover { background: #33cc33; }
|
| 112 |
button.danger-button { background: #c00; }
|
| 113 |
button.danger-button:hover { background: #e00; }
|
| 114 |
-
#tool-selector { display:
|
| 115 |
.tool-item {
|
| 116 |
-
padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align:
|
| 117 |
transition: all 0.2s; font-size: 0.9em;
|
| 118 |
}
|
| 119 |
.tool-item:hover { background: #3a3a3a; border-color: #666; }
|
| 120 |
.tool-item.active { background: #0077cc; border-color: #00aaff; }
|
| 121 |
#loading-spinner {
|
| 122 |
-
position:
|
| 123 |
-
border:
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
#burger-menu {
|
| 127 |
position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
|
|
@@ -129,13 +130,13 @@ EDITOR_TEMPLATE = '''
|
|
| 129 |
#burger-menu span { display: block; position: absolute; height: 4px; width: 100%; background: white; border-radius: 2px; opacity: 1; left: 0; transition: .25s ease-in-out; }
|
| 130 |
#burger-menu span:nth-child(1) { top: 0px; } #burger-menu span:nth-child(2) { top: 9px; } #burger-menu span:nth-child(3) { top: 18px; }
|
| 131 |
#joystick-container {
|
| 132 |
-
position:
|
| 133 |
-
display: none; z-index: 100; pointer-events: auto; user-select: none;
|
| 134 |
}
|
| 135 |
#joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
|
| 136 |
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 137 |
@media (max-width: 800px) {
|
| 138 |
-
#ui-panel { transform: translateX(-100%); padding-top: 60px; }
|
| 139 |
#ui-panel.open { transform: translateX(0); }
|
| 140 |
#burger-menu { display: block; }
|
| 141 |
#joystick-container { width: 100px; height: 100px; }
|
|
@@ -164,14 +165,20 @@ EDITOR_TEMPLATE = '''
|
|
| 164 |
<h3>Режим</h3>
|
| 165 |
<button id="play-mode-toggle" class="play-button">Играть</button>
|
| 166 |
</div>
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
<h3>Инструменты</h3>
|
| 169 |
<div id="tool-selector"></div>
|
| 170 |
-
<
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
| 175 |
</p>
|
| 176 |
<button id="clear-level" class="danger-button">Очистить уровень</button>
|
| 177 |
</div>
|
|
@@ -180,77 +187,81 @@ EDITOR_TEMPLATE = '''
|
|
| 180 |
<div id="loading-spinner"></div>
|
| 181 |
<div id="joystick-container"><div id="joystick-handle"></div></div>
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
<script type="importmap">
|
| 184 |
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
|
| 185 |
</script>
|
| 186 |
<script type="module">
|
| 187 |
import * as THREE from 'three';
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
let scene, camera, renderer,
|
|
|
|
| 190 |
|
| 191 |
let isPlayMode = false;
|
| 192 |
let player, playerVelocity = new THREE.Vector3();
|
| 193 |
const playerSpeed = 5.0;
|
| 194 |
const keyStates = {};
|
| 195 |
const clock = new THREE.Clock();
|
| 196 |
-
let
|
| 197 |
|
| 198 |
-
let
|
| 199 |
let currentRotation = 0;
|
| 200 |
const gridSize = 1;
|
| 201 |
-
|
|
|
|
| 202 |
|
| 203 |
const joystick = { active: false, center: new THREE.Vector2(), current: new THREE.Vector2(), vector: new THREE.Vector2() };
|
| 204 |
-
|
| 205 |
-
const isMobile = 'ontouchstart' in window;
|
| 206 |
-
|
| 207 |
const textureLoader = new THREE.TextureLoader();
|
| 208 |
-
const
|
| 209 |
-
|
| 210 |
-
function loadTexture(url) {
|
| 211 |
-
if (loadedTextures[url]) return loadedTextures[url];
|
| 212 |
-
const texture = textureLoader.load(url);
|
| 213 |
-
texture.wrapS = THREE.RepeatWrapping;
|
| 214 |
-
texture.wrapT = THREE.RepeatWrapping;
|
| 215 |
-
texture.magFilter = THREE.NearestFilter;
|
| 216 |
-
texture.minFilter = THREE.NearestFilter;
|
| 217 |
-
loadedTextures[url] = texture;
|
| 218 |
-
return texture;
|
| 219 |
-
}
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
grass_4x4: { size: [4, 0.1, 4], mapUrl: 'https://i.ibb.co/FqsRw2T/grass.png', repeat: [4,4] },
|
| 225 |
-
concrete: { size: [1, 0.1, 1], mapUrl: 'https://i.ibb.co/yQdGbpS/concrete.png' },
|
| 226 |
-
concrete_4x4: { size: [4, 0.1, 4], mapUrl: 'https://i.ibb.co/yQdGbpS/concrete.png', repeat: [4,4] },
|
| 227 |
-
wood: { size: [1, 0.1, 1], mapUrl: 'https://i.ibb.co/GvxPb7k/wood.png' },
|
| 228 |
-
dirt: { size: [1, 0.1, 1], mapUrl: 'https://i.ibb.co/P9tLdZn/dirt.png' }
|
| 229 |
-
},
|
| 230 |
-
walls: {
|
| 231 |
-
brick: { size: [1, 2.5, 0.2], mapUrl: 'https://i.ibb.co/PMNtV6z/brick.png', repeat: [1,2] },
|
| 232 |
-
concrete: { size: [1, 2.5, 0.2], mapUrl: 'https://i.ibb.co/VMyXgV8/wall-concrete.png', repeat: [1,2] }
|
| 233 |
-
},
|
| 234 |
-
objects: {
|
| 235 |
-
crate: { size: [0.8, 0.8, 0.8], mapUrl: 'https://i.ibb.co/PZ9rV3g/crate.png' },
|
| 236 |
-
barrel: { size: [0.4, 0.8, 0], geometry: 'cylinder', mapUrl: 'https://i.ibb.co/PcnwQqp/barrel.png' }
|
| 237 |
}
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
| 243 |
|
| 244 |
function init() {
|
| 245 |
scene = new THREE.Scene();
|
| 246 |
-
scene.background = new THREE.Color(
|
| 247 |
-
scene.fog = new THREE.Fog(0x3d4a55, 50, 150);
|
| 248 |
|
| 249 |
const aspect = window.innerWidth / window.innerHeight;
|
| 250 |
const frustumSize = 30;
|
| 251 |
camera = new THREE.OrthographicCamera(frustumSize * aspect / -2, frustumSize * aspect / 2, frustumSize / 2, frustumSize / -2, 1, 1000);
|
| 252 |
-
camera.position.set(
|
| 253 |
-
camera.lookAt(0,0,0);
|
| 254 |
|
| 255 |
renderer = new THREE.WebGLRenderer({ antialias: true });
|
| 256 |
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
@@ -258,49 +269,60 @@ EDITOR_TEMPLATE = '''
|
|
| 258 |
renderer.shadowMap.enabled = true;
|
| 259 |
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 260 |
document.body.appendChild(renderer.domElement);
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
scene.add(hemiLight);
|
| 264 |
|
| 265 |
-
const dirLight = new THREE.DirectionalLight(0xffffff,
|
| 266 |
dirLight.position.set(-30, 50, -30);
|
| 267 |
dirLight.castShadow = true;
|
| 268 |
-
dirLight.shadow.mapSize.width =
|
| 269 |
-
dirLight.shadow.mapSize.height =
|
| 270 |
-
dirLight.shadow.camera.left = -
|
| 271 |
-
dirLight.shadow.camera.right =
|
| 272 |
-
dirLight.shadow.camera.top =
|
| 273 |
-
dirLight.shadow.camera.bottom = -
|
| 274 |
scene.add(dirLight);
|
| 275 |
|
| 276 |
-
gridHelper = new THREE.GridHelper(
|
| 277 |
scene.add(gridHelper);
|
| 278 |
|
| 279 |
-
const planeGeo = new THREE.PlaneGeometry(
|
| 280 |
planeGeo.rotateX(-Math.PI / 2);
|
| 281 |
-
placementPlane = new THREE.Mesh(planeGeo, new THREE.
|
|
|
|
| 282 |
scene.add(placementPlane);
|
| 283 |
|
| 284 |
raycaster = new THREE.Raycaster();
|
| 285 |
mouse = new THREE.Vector2();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
initUI();
|
| 288 |
initPlayer();
|
| 289 |
initJoystick();
|
| 290 |
-
initInstancedMeshes();
|
| 291 |
|
| 292 |
window.addEventListener('resize', onWindowResize);
|
| 293 |
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
| 294 |
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
| 295 |
-
|
| 296 |
-
window.addEventListener('keydown', e => { keyStates[e.code] = true; handleKeyDown(e); });
|
| 297 |
window.addEventListener('keyup', e => { keyStates[e.code] = false; });
|
| 298 |
renderer.domElement.addEventListener('wheel', e => {
|
| 299 |
-
if(!isPlayMode) {
|
| 300 |
e.preventDefault();
|
| 301 |
-
|
| 302 |
-
camera.zoom *= zoomAmount;
|
| 303 |
-
camera.updateProjectionMatrix();
|
| 304 |
}
|
| 305 |
}, { passive: false });
|
| 306 |
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
|
@@ -308,35 +330,9 @@ EDITOR_TEMPLATE = '''
|
|
| 308 |
animate();
|
| 309 |
}
|
| 310 |
|
| 311 |
-
function initInstancedMeshes() {
|
| 312 |
-
const MAX_COUNT = 10000;
|
| 313 |
-
for (const category in ASSETS) {
|
| 314 |
-
instancedMeshes[category] = {};
|
| 315 |
-
for (const type in ASSETS[category]) {
|
| 316 |
-
const asset = ASSETS[category][type];
|
| 317 |
-
const texture = loadTexture(asset.mapUrl);
|
| 318 |
-
if(asset.repeat) texture.repeat.set(...asset.repeat);
|
| 319 |
-
|
| 320 |
-
let geometry;
|
| 321 |
-
if(asset.geometry === 'cylinder') {
|
| 322 |
-
geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
|
| 323 |
-
} else {
|
| 324 |
-
geometry = new THREE.BoxGeometry(...asset.size);
|
| 325 |
-
}
|
| 326 |
-
const material = new THREE.MeshStandardMaterial({ map: texture });
|
| 327 |
-
const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);
|
| 328 |
-
mesh.castShadow = true;
|
| 329 |
-
mesh.receiveShadow = true;
|
| 330 |
-
mesh.count = 0;
|
| 331 |
-
instancedMeshes[category][type] = mesh;
|
| 332 |
-
scene.add(mesh);
|
| 333 |
-
}
|
| 334 |
-
}
|
| 335 |
-
}
|
| 336 |
-
|
| 337 |
function initPlayer() {
|
| 338 |
-
const playerGeo = new THREE.CylinderGeometry(0.
|
| 339 |
-
playerGeo.translate(0, 0.
|
| 340 |
const playerMat = new THREE.MeshStandardMaterial({ color: 0x00aaff });
|
| 341 |
player = new THREE.Mesh(playerGeo, playerMat);
|
| 342 |
player.castShadow = true;
|
|
@@ -345,38 +341,39 @@ EDITOR_TEMPLATE = '''
|
|
| 345 |
}
|
| 346 |
|
| 347 |
function initUI() {
|
| 348 |
-
const toolSelector = document.getElementById('tool-selector');
|
| 349 |
-
toolSelector.innerHTML = '';
|
| 350 |
-
for (const category in ASSETS) {
|
| 351 |
-
for (const type in ASSETS[category]) {
|
| 352 |
-
const item = document.createElement('div');
|
| 353 |
-
item.className = 'tool-item';
|
| 354 |
-
item.textContent = `${category.slice(0,1).toUpperCase()}: ${type.replace('_',' ')}`;
|
| 355 |
-
item.dataset.category = category;
|
| 356 |
-
item.dataset.type = type;
|
| 357 |
-
item.addEventListener('click', () => selectTool(category, type));
|
| 358 |
-
toolSelector.appendChild(item);
|
| 359 |
-
}
|
| 360 |
-
}
|
| 361 |
-
selectTool('floors', 'grass');
|
| 362 |
-
|
| 363 |
document.getElementById('save-project').addEventListener('click', saveProject);
|
| 364 |
document.getElementById('load-project').addEventListener('click', loadProject);
|
| 365 |
document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
|
| 366 |
document.getElementById('clear-level').addEventListener('click', clearLevel);
|
| 367 |
document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open'));
|
| 368 |
document.getElementById('project-list').addEventListener('change', e => document.getElementById('project-name').value = e.target.value);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
}
|
| 370 |
-
|
| 371 |
function initJoystick() {
|
| 372 |
const joystickContainer = document.getElementById('joystick-container');
|
| 373 |
const joystickHandle = document.getElementById('joystick-handle');
|
| 374 |
const maxRadius = joystickContainer.clientWidth / 2;
|
| 375 |
|
| 376 |
function onTouchStart(event) {
|
|
|
|
| 377 |
const touch = event.touches[0];
|
| 378 |
joystick.active = true;
|
| 379 |
joystick.center.set(touch.clientX, touch.clientY);
|
|
|
|
|
|
|
| 380 |
}
|
| 381 |
function onTouchMove(event) {
|
| 382 |
if (!joystick.active) return;
|
|
@@ -391,35 +388,58 @@ EDITOR_TEMPLATE = '''
|
|
| 391 |
joystick.vector.set(0, 0);
|
| 392 |
joystickHandle.style.transform = 'translate(0, 0)';
|
| 393 |
}
|
| 394 |
-
|
| 395 |
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
| 396 |
window.addEventListener('touchend', onTouchEnd);
|
| 397 |
}
|
| 398 |
-
|
| 399 |
-
function selectTool(
|
| 400 |
-
|
| 401 |
document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
|
| 402 |
-
const activeEl = document.querySelector(`.tool-item[data-
|
| 403 |
if (activeEl) activeEl.classList.add('active');
|
| 404 |
|
| 405 |
-
if (previewMesh)
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
}
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
}
|
| 419 |
|
| 420 |
function onWindowResize() {
|
| 421 |
const aspect = window.innerWidth / window.innerHeight;
|
| 422 |
-
const frustumSize = 30
|
| 423 |
camera.left = frustumSize * aspect / -2;
|
| 424 |
camera.right = frustumSize * aspect / 2;
|
| 425 |
camera.top = frustumSize / 2;
|
|
@@ -429,155 +449,163 @@ EDITOR_TEMPLATE = '''
|
|
| 429 |
}
|
| 430 |
|
| 431 |
function onPointerMove(event) {
|
|
|
|
| 432 |
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
| 433 |
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
| 434 |
-
|
| 435 |
-
if(
|
| 436 |
-
|
| 437 |
-
const
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
if (intersects.length > 0) {
|
| 449 |
-
const point = intersects[0].point;
|
| 450 |
-
const asset = ASSETS[currentTool.category][currentTool.type];
|
| 451 |
-
const w = (asset.size[0] / gridSize);
|
| 452 |
-
const h = (asset.size[2] / gridSize);
|
| 453 |
-
|
| 454 |
-
const gridX = Math.round(point.x / gridSize);
|
| 455 |
-
const gridZ = Math.round(point.z / gridSize);
|
| 456 |
-
|
| 457 |
-
let finalX = gridX * gridSize;
|
| 458 |
-
let finalZ = gridZ * gridSize;
|
| 459 |
-
|
| 460 |
-
if (w > 1) finalX += (w/2-0.5) * Math.sign(finalX) * gridSize;
|
| 461 |
-
if (h > 1) finalZ += (h/2-0.5) * Math.sign(finalZ) * gridSize;
|
| 462 |
-
|
| 463 |
-
previewMesh.position.set(finalX, 0, finalZ);
|
| 464 |
-
|
| 465 |
-
if (currentTool.category === 'floors') {
|
| 466 |
-
previewMesh.position.y = asset.size[1] / 2;
|
| 467 |
} else {
|
| 468 |
-
previewMesh.
|
| 469 |
}
|
| 470 |
-
previewMesh.visible = true;
|
| 471 |
-
} else {
|
| 472 |
-
previewMesh.visible = false;
|
| 473 |
}
|
| 474 |
}
|
| 475 |
|
| 476 |
function handleKeyDown(event) {
|
|
|
|
| 477 |
if (isPlayMode) return;
|
|
|
|
| 478 |
if (event.code === 'KeyR') {
|
| 479 |
rotatePreview(1);
|
| 480 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
}
|
| 482 |
|
| 483 |
function onPointerDown(event) {
|
| 484 |
-
if (isPlayMode
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
if (!isPlayMode && event.button === 2) {
|
| 488 |
-
isPanning = true;
|
| 489 |
-
panStart.set(event.clientX, event.clientY);
|
| 490 |
return;
|
| 491 |
}
|
|
|
|
| 492 |
|
| 493 |
-
|
| 494 |
-
const isRemoving = event.shiftKey;
|
| 495 |
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
function onPointerUp(event) {
|
| 508 |
-
if (event.button === 2) {
|
| 509 |
-
isPanning = false;
|
| 510 |
}
|
| 511 |
}
|
| 512 |
|
| 513 |
function rotatePreview(direction) {
|
| 514 |
-
|
| 515 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
}
|
| 517 |
|
| 518 |
function addItem(pos, rotation) {
|
| 519 |
-
const
|
| 520 |
-
|
| 521 |
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
}
|
| 528 |
|
| 529 |
-
function
|
| 530 |
-
const
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
|
|
|
| 538 |
}
|
| 539 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
|
| 541 |
-
function
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
const
|
| 548 |
-
|
| 549 |
-
mesh.count = dataArray.length;
|
| 550 |
-
|
| 551 |
-
for (let i = 0; i < dataArray.length; i++) {
|
| 552 |
-
const [x, z, rot] = dataArray[i];
|
| 553 |
-
const asset = ASSETS[category][type];
|
| 554 |
-
|
| 555 |
-
dummy.position.set(x, 0, z);
|
| 556 |
-
dummy.rotation.set(0, rot, 0);
|
| 557 |
-
|
| 558 |
-
dummy.position.y = (category === 'floors' ? asset.size[1] / 2 : asset.size[1] / 2);
|
| 559 |
-
|
| 560 |
-
dummy.updateMatrix();
|
| 561 |
-
mesh.setMatrixAt(i, dummy.matrix);
|
| 562 |
-
|
| 563 |
-
if (category === 'walls' || category === 'objects') {
|
| 564 |
-
const boxHelper = new THREE.BoxHelper(dummy);
|
| 565 |
-
boxHelper.geometry.computeBoundingBox();
|
| 566 |
-
const box = boxHelper.geometry.boundingBox;
|
| 567 |
-
wallBBoxes.push(box);
|
| 568 |
-
}
|
| 569 |
-
}
|
| 570 |
-
mesh.instanceMatrix.needsUpdate = true;
|
| 571 |
}
|
| 572 |
-
}
|
| 573 |
}
|
| 574 |
|
| 575 |
function clearLevel() {
|
| 576 |
if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
|
| 577 |
-
levelData.
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
}
|
| 582 |
|
| 583 |
function togglePlayMode() {
|
|
@@ -587,24 +615,28 @@ EDITOR_TEMPLATE = '''
|
|
| 587 |
|
| 588 |
if (isPlayMode) {
|
| 589 |
uiContainer.style.display = 'none';
|
| 590 |
-
|
| 591 |
-
|
|
|
|
|
|
|
| 592 |
player.visible = true;
|
| 593 |
player.position.set(0, 0, 0);
|
| 594 |
-
if(
|
|
|
|
|
|
|
| 595 |
} else {
|
| 596 |
uiContainer.style.display = 'flex';
|
| 597 |
-
|
| 598 |
-
previewMesh.visible = true;
|
|
|
|
| 599 |
player.visible = false;
|
| 600 |
joystickContainer.style.display = 'none';
|
| 601 |
-
camera.position.set(
|
| 602 |
camera.lookAt(0,0,0);
|
| 603 |
-
|
| 604 |
-
camera.updateProjectionMatrix();
|
| 605 |
}
|
| 606 |
}
|
| 607 |
-
|
| 608 |
async function saveProject() {
|
| 609 |
const projectName = document.getElementById('project-name').value.trim();
|
| 610 |
if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
|
|
@@ -617,12 +649,7 @@ EDITOR_TEMPLATE = '''
|
|
| 617 |
});
|
| 618 |
if (!response.ok) throw new Error('Ошибка при сохранении проекта.');
|
| 619 |
alert(`Проект '${projectName}' успешно сохранен!`);
|
| 620 |
-
|
| 621 |
-
if (![...projectList.options].some(opt => opt.value === projectName)) {
|
| 622 |
-
const newOption = document.createElement('option');
|
| 623 |
-
newOption.value = newOption.textContent = projectName;
|
| 624 |
-
projectList.appendChild(newOption);
|
| 625 |
-
}
|
| 626 |
} catch (error) {
|
| 627 |
alert(`Не удалось сохранить проект: ${error.message}`);
|
| 628 |
} finally {
|
|
@@ -636,12 +663,16 @@ EDITOR_TEMPLATE = '''
|
|
| 636 |
showSpinner(true);
|
| 637 |
try {
|
| 638 |
const response = await fetch(`/api/project/${projectName}`);
|
| 639 |
-
const result = await response.json();
|
| 640 |
if (!response.ok) throw new Error('Проект не найден или ошибка загрузки.');
|
|
|
|
| 641 |
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
document.getElementById('project-name').value = projectName;
|
| 646 |
|
| 647 |
} catch (error) {
|
|
@@ -650,10 +681,70 @@ EDITOR_TEMPLATE = '''
|
|
| 650 |
showSpinner(false);
|
| 651 |
}
|
| 652 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
function updatePlayer(deltaTime) {
|
| 658 |
const speed = playerSpeed * deltaTime;
|
| 659 |
moveDirection.set(0,0,0);
|
|
@@ -676,7 +767,7 @@ EDITOR_TEMPLATE = '''
|
|
| 676 |
const intendedXPos = player.position.clone().add(new THREE.Vector3(moveDirection.x, 0, 0));
|
| 677 |
const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0));
|
| 678 |
let collisionX = false;
|
| 679 |
-
for(const wallBox of
|
| 680 |
if (playerBoxX.intersectsBox(wallBox)) {
|
| 681 |
collisionX = true;
|
| 682 |
break;
|
|
@@ -686,8 +777,8 @@ EDITOR_TEMPLATE = '''
|
|
| 686 |
|
| 687 |
const intendedZPos = player.position.clone().add(new THREE.Vector3(0, 0, moveDirection.z));
|
| 688 |
const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z));
|
| 689 |
-
|
| 690 |
-
for(const wallBox of
|
| 691 |
if (playerBoxZ.intersectsBox(wallBox)) {
|
| 692 |
collisionZ = true;
|
| 693 |
break;
|
|
@@ -695,9 +786,10 @@ EDITOR_TEMPLATE = '''
|
|
| 695 |
}
|
| 696 |
if(!collisionZ) player.position.z = intendedZPos.z;
|
| 697 |
|
| 698 |
-
camera.position.
|
|
|
|
|
|
|
| 699 |
camera.lookAt(player.position);
|
| 700 |
-
camera.updateProjectionMatrix();
|
| 701 |
}
|
| 702 |
|
| 703 |
function showSpinner(show) {
|
|
@@ -710,8 +802,9 @@ EDITOR_TEMPLATE = '''
|
|
| 710 |
|
| 711 |
if (isPlayMode) {
|
| 712 |
updatePlayer(deltaTime);
|
|
|
|
|
|
|
| 713 |
}
|
| 714 |
-
|
| 715 |
renderer.render(scene, camera);
|
| 716 |
}
|
| 717 |
|
|
@@ -723,8 +816,9 @@ EDITOR_TEMPLATE = '''
|
|
| 723 |
|
| 724 |
@app.route('/')
|
| 725 |
def editor():
|
| 726 |
-
projects =
|
| 727 |
-
|
|
|
|
| 728 |
|
| 729 |
@app.route('/api/project', methods=['POST'])
|
| 730 |
def save_project_api():
|
|
@@ -734,13 +828,14 @@ def save_project_api():
|
|
| 734 |
return jsonify({"error": "Project name is required"}), 400
|
| 735 |
|
| 736 |
local_filename = f"{uuid4().hex}.json"
|
| 737 |
-
|
|
|
|
| 738 |
json.dump(data, f)
|
| 739 |
|
| 740 |
-
success =
|
| 741 |
|
| 742 |
-
if os.path.exists(
|
| 743 |
-
os.remove(
|
| 744 |
|
| 745 |
if success:
|
| 746 |
return jsonify({"message": "Project saved successfully"}), 201
|
|
@@ -749,12 +844,49 @@ def save_project_api():
|
|
| 749 |
|
| 750 |
@app.route('/api/project/<project_name>', methods=['GET'])
|
| 751 |
def load_project_api(project_name):
|
| 752 |
-
|
| 753 |
-
if
|
|
|
|
|
|
|
|
|
|
| 754 |
return jsonify(project_data)
|
| 755 |
else:
|
| 756 |
return jsonify({"error": "Project not found or failed to download"}), 404
|
| 757 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
if __name__ == '__main__':
|
| 759 |
port = int(os.environ.get('PORT', 7860))
|
| 760 |
app.run(debug=False, host='0.0.0.0', port=port)
|
|
|
|
| 4 |
from datetime import datetime
|
| 5 |
from uuid import uuid4
|
| 6 |
|
| 7 |
+
from flask import Flask, render_template_string, request, jsonify, send_from_directory
|
| 8 |
+
from huggingface_hub import HfApi, hf_hub_download, list_files_info
|
| 9 |
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
+
from werkzeug.utils import secure_filename
|
| 12 |
|
| 13 |
load_dotenv()
|
| 14 |
|
| 15 |
app = Flask(__name__)
|
| 16 |
app.secret_key = 'level_designer_secret_key_zomboid_5678'
|
| 17 |
+
app.config['UPLOAD_FOLDER'] = 'temp_uploads'
|
| 18 |
+
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
| 19 |
|
| 20 |
REPO_ID = "Kgshop/Testai"
|
| 21 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 22 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 23 |
|
| 24 |
+
def get_api_client():
|
| 25 |
+
token = HF_TOKEN_WRITE if HF_TOKEN_WRITE else HF_TOKEN_READ
|
| 26 |
+
if not token:
|
| 27 |
+
return None, None
|
| 28 |
+
return HfApi(token=token), token
|
| 29 |
+
|
| 30 |
+
def upload_to_hf(local_path, path_in_repo, commit_message):
|
| 31 |
+
api, _ = get_api_client()
|
| 32 |
+
if not api or not HF_TOKEN_WRITE:
|
| 33 |
+
print("Write token not available. Upload skipped.")
|
| 34 |
return False
|
| 35 |
try:
|
|
|
|
| 36 |
api.upload_file(
|
| 37 |
path_or_fileobj=local_path,
|
| 38 |
+
path_in_repo=path_in_repo,
|
| 39 |
repo_id=REPO_ID,
|
| 40 |
repo_type="dataset",
|
| 41 |
+
commit_message=commit_message
|
|
|
|
| 42 |
)
|
| 43 |
return True
|
| 44 |
except Exception as e:
|
| 45 |
+
print(f"Error uploading to HF: {e}")
|
| 46 |
return False
|
| 47 |
|
| 48 |
+
def download_from_hf(path_in_repo):
|
| 49 |
+
_, token = get_api_client()
|
| 50 |
+
if not token:
|
| 51 |
return None
|
| 52 |
try:
|
| 53 |
local_path = hf_hub_download(
|
| 54 |
repo_id=REPO_ID,
|
| 55 |
+
filename=path_in_repo,
|
| 56 |
repo_type="dataset",
|
| 57 |
+
token=token,
|
| 58 |
local_dir=".",
|
| 59 |
local_dir_use_symlinks=False,
|
| 60 |
force_download=True
|
| 61 |
)
|
| 62 |
+
return local_path
|
| 63 |
+
except (HfHubHTTPError, RepositoryNotFoundError):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return None
|
| 65 |
+
except Exception:
|
| 66 |
return None
|
| 67 |
|
| 68 |
+
def list_files_from_hf(folder):
|
| 69 |
+
api, token = get_api_client()
|
| 70 |
+
if not api:
|
| 71 |
return []
|
| 72 |
try:
|
| 73 |
+
files_info = list_files_info(repo_id=REPO_ID, repo_type="dataset", token=token, paths=[folder])
|
| 74 |
+
return sorted([os.path.basename(f.rfilename) for f in files_info if not f.rfilename.endswith('/.gitattributes')])
|
|
|
|
|
|
|
| 75 |
except Exception:
|
| 76 |
return []
|
| 77 |
|
|
|
|
| 87 |
canvas { display: block; }
|
| 88 |
#ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; display: flex; }
|
| 89 |
#ui-panel {
|
| 90 |
+
background: rgba(10, 10, 10, 0.9); backdrop-filter: blur(5px); padding: 15px; border-right: 1px solid #444;
|
| 91 |
+
width: 350px; height: 100%; overflow-y: auto; box-sizing: border-box; transition: transform 0.3s ease-in-out;
|
| 92 |
+
transform: translateX(0); pointer-events: auto; display: flex; flex-direction: column;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
.ui-group { margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }
|
| 95 |
.ui-group:last-child { border-bottom: none; }
|
| 96 |
h3 { margin-top: 0; font-size: 1.2em; color: #00aaff; border-bottom: 1px solid #00aaff; padding-bottom: 5px; margin-bottom: 10px; }
|
| 97 |
label { display: block; margin-bottom: 5px; font-size: 0.9em; }
|
| 98 |
+
input[type="text"], input[type="number"], select, input[type="file"] {
|
| 99 |
+
width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; margin-bottom: 10px;
|
| 100 |
}
|
| 101 |
+
input[type="checkbox"] { margin-right: 5px; }
|
| 102 |
button {
|
| 103 |
+
width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold;
|
| 104 |
+
margin-top: 5px; transition: background-color 0.2s;
|
| 105 |
}
|
| 106 |
button:hover { background: #0099ff; }
|
| 107 |
button.play-button { background: #22aa22; }
|
| 108 |
button.play-button:hover { background: #33cc33; }
|
| 109 |
button.danger-button { background: #c00; }
|
| 110 |
button.danger-button:hover { background: #e00; }
|
| 111 |
+
#tool-selector { display: flex; flex-direction: column; gap: 10px; max-height: 250px; overflow-y: auto; padding: 5px; border: 1px solid #333; border-radius: 4px; }
|
| 112 |
.tool-item {
|
| 113 |
+
padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align: left; cursor: pointer;
|
| 114 |
transition: all 0.2s; font-size: 0.9em;
|
| 115 |
}
|
| 116 |
.tool-item:hover { background: #3a3a3a; border-color: #666; }
|
| 117 |
.tool-item.active { background: #0077cc; border-color: #00aaff; }
|
| 118 |
#loading-spinner {
|
| 119 |
+
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 8px solid #f3f3f3; border-top: 8px solid #3498db;
|
| 120 |
+
border-radius: 50%; width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 1001;
|
| 121 |
+
}
|
| 122 |
+
#modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; display: none; }
|
| 123 |
+
#asset-modal {
|
| 124 |
+
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1e1e1e; padding: 20px;
|
| 125 |
+
border-radius: 8px; z-index: 1001; width: 90%; max-width: 400px; box-shadow: 0 5px 15px rgba(0,0,0,0.5);
|
| 126 |
}
|
| 127 |
#burger-menu {
|
| 128 |
position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
|
|
|
|
| 130 |
#burger-menu span { display: block; position: absolute; height: 4px; width: 100%; background: white; border-radius: 2px; opacity: 1; left: 0; transition: .25s ease-in-out; }
|
| 131 |
#burger-menu span:nth-child(1) { top: 0px; } #burger-menu span:nth-child(2) { top: 9px; } #burger-menu span:nth-child(3) { top: 18px; }
|
| 132 |
#joystick-container {
|
| 133 |
+
position: fixed; bottom: 30px; left: 30px; width: 120px; height: 120px; background: rgba(128, 128, 128, 0.3);
|
| 134 |
+
border-radius: 50%; display: none; z-index: 100; pointer-events: auto; user-select: none;
|
| 135 |
}
|
| 136 |
#joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
|
| 137 |
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
| 138 |
@media (max-width: 800px) {
|
| 139 |
+
#ui-panel { transform: translateX(-100%); padding-top: 60px; width: 300px; }
|
| 140 |
#ui-panel.open { transform: translateX(0); }
|
| 141 |
#burger-menu { display: block; }
|
| 142 |
#joystick-container { width: 100px; height: 100px; }
|
|
|
|
| 165 |
<h3>Режим</h3>
|
| 166 |
<button id="play-mode-toggle" class="play-button">Играть</button>
|
| 167 |
</div>
|
| 168 |
+
<div class="ui-group">
|
| 169 |
+
<h3>Текстуры</h3>
|
| 170 |
+
<input type="file" id="texture-upload" accept="image/*">
|
| 171 |
+
<button id="upload-texture-btn">Загрузить текстуру</button>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="ui-group" style="flex: 1; display: flex; flex-direction: column;">
|
| 174 |
<h3>Инструменты</h3>
|
| 175 |
<div id="tool-selector"></div>
|
| 176 |
+
<button id="add-asset-btn" style="margin-top: 10px;">Создать новый объект</button>
|
| 177 |
+
<p style="font-size: 0.8em; color: #888; margin-top: auto;">
|
| 178 |
+
ЛКМ: Разместить/Выбрать<br>
|
| 179 |
+
ПКМ / Колесо мыши: Вращать<br>
|
| 180 |
+
Delete: Удалить выбранный<br>
|
| 181 |
+
ESC: Снять выделение
|
| 182 |
</p>
|
| 183 |
<button id="clear-level" class="danger-button">Очистить уровень</button>
|
| 184 |
</div>
|
|
|
|
| 187 |
<div id="loading-spinner"></div>
|
| 188 |
<div id="joystick-container"><div id="joystick-handle"></div></div>
|
| 189 |
|
| 190 |
+
<div id="modal-backdrop">
|
| 191 |
+
<div id="asset-modal">
|
| 192 |
+
<h3>Создание объекта</h3>
|
| 193 |
+
<label for="asset-name">Имя:</label>
|
| 194 |
+
<input type="text" id="asset-name" placeholder="e.g., brick_wall">
|
| 195 |
+
<label for="asset-texture">Текстура:</label>
|
| 196 |
+
<select id="asset-texture">
|
| 197 |
+
{% for texture in textures %}
|
| 198 |
+
<option value="{{ texture }}">{{ texture }}</option>
|
| 199 |
+
{% endfor %}
|
| 200 |
+
</select>
|
| 201 |
+
<label for="asset-width">Ширина:</label>
|
| 202 |
+
<input type="number" id="asset-width" value="1">
|
| 203 |
+
<label for="asset-height">Высота:</label>
|
| 204 |
+
<input type="number" id="asset-height" value="2.5">
|
| 205 |
+
<label for="asset-depth">Глубина:</label>
|
| 206 |
+
<input type="number" id="asset-depth" value="0.2">
|
| 207 |
+
<label><input type="checkbox" id="asset-solid" checked> Непроходимый (solid)</label>
|
| 208 |
+
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
| 209 |
+
<button id="save-asset-btn">Сохранить</button>
|
| 210 |
+
<button id="cancel-asset-btn" class="danger-button">Отмена</button>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
<script type="importmap">
|
| 216 |
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
|
| 217 |
</script>
|
| 218 |
<script type="module">
|
| 219 |
import * as THREE from 'three';
|
| 220 |
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
| 221 |
+
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
| 222 |
|
| 223 |
+
let scene, camera, renderer, orbitControls, transformControls;
|
| 224 |
+
let raycaster, mouse, placementPlane, previewMesh;
|
| 225 |
|
| 226 |
let isPlayMode = false;
|
| 227 |
let player, playerVelocity = new THREE.Vector3();
|
| 228 |
const playerSpeed = 5.0;
|
| 229 |
const keyStates = {};
|
| 230 |
const clock = new THREE.Clock();
|
| 231 |
+
let collisionBBoxes = [];
|
| 232 |
|
| 233 |
+
let currentToolId = null;
|
| 234 |
let currentRotation = 0;
|
| 235 |
const gridSize = 1;
|
| 236 |
+
let levelData = { assets: {}, placements: [] };
|
| 237 |
+
let placedObjects = new Map();
|
| 238 |
|
| 239 |
const joystick = { active: false, center: new THREE.Vector2(), current: new THREE.Vector2(), vector: new THREE.Vector2() };
|
| 240 |
+
|
|
|
|
|
|
|
| 241 |
const textureLoader = new THREE.TextureLoader();
|
| 242 |
+
const textureCache = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
function loadTexture(textureFile) {
|
| 245 |
+
if (textureCache.has(textureFile)) {
|
| 246 |
+
return textureCache.get(textureFile);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
}
|
| 248 |
+
const texture = textureLoader.load(`/api/texture/${textureFile}`, (tex) => {
|
| 249 |
+
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
| 250 |
+
tex.colorSpace = THREE.SRGBColorSpace;
|
| 251 |
+
});
|
| 252 |
+
textureCache.set(textureFile, texture);
|
| 253 |
+
return texture;
|
| 254 |
+
}
|
| 255 |
|
| 256 |
function init() {
|
| 257 |
scene = new THREE.Scene();
|
| 258 |
+
scene.background = new THREE.Color(0x3d4247);
|
|
|
|
| 259 |
|
| 260 |
const aspect = window.innerWidth / window.innerHeight;
|
| 261 |
const frustumSize = 30;
|
| 262 |
camera = new THREE.OrthographicCamera(frustumSize * aspect / -2, frustumSize * aspect / 2, frustumSize / 2, frustumSize / -2, 1, 1000);
|
| 263 |
+
camera.position.set(50, 50, 50);
|
| 264 |
+
camera.lookAt(0, 0, 0);
|
| 265 |
|
| 266 |
renderer = new THREE.WebGLRenderer({ antialias: true });
|
| 267 |
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
|
|
| 269 |
renderer.shadowMap.enabled = true;
|
| 270 |
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 271 |
document.body.appendChild(renderer.domElement);
|
| 272 |
+
|
| 273 |
+
orbitControls = new OrbitControls(camera, renderer.domElement);
|
| 274 |
+
orbitControls.enableDamping = true;
|
| 275 |
+
orbitControls.minZoom = 0.5;
|
| 276 |
+
orbitControls.maxZoom = 4;
|
| 277 |
+
orbitControls.enableRotate = true;
|
| 278 |
+
orbitControls.minPolarAngle = Math.PI / 6;
|
| 279 |
+
orbitControls.maxPolarAngle = Math.PI / 3;
|
| 280 |
+
|
| 281 |
+
const hemiLight = new THREE.HemisphereLight(0xB1E1FF, 0x4e5157, 1.5);
|
| 282 |
scene.add(hemiLight);
|
| 283 |
|
| 284 |
+
const dirLight = new THREE.DirectionalLight(0xffffff, 2.5);
|
| 285 |
dirLight.position.set(-30, 50, -30);
|
| 286 |
dirLight.castShadow = true;
|
| 287 |
+
dirLight.shadow.mapSize.width = 4096;
|
| 288 |
+
dirLight.shadow.mapSize.height = 4096;
|
| 289 |
+
dirLight.shadow.camera.left = -80;
|
| 290 |
+
dirLight.shadow.camera.right = 80;
|
| 291 |
+
dirLight.shadow.camera.top = 80;
|
| 292 |
+
dirLight.shadow.camera.bottom = -80;
|
| 293 |
scene.add(dirLight);
|
| 294 |
|
| 295 |
+
const gridHelper = new THREE.GridHelper(200, 200, 0x555555, 0x555555);
|
| 296 |
scene.add(gridHelper);
|
| 297 |
|
| 298 |
+
const planeGeo = new THREE.PlaneGeometry(200, 200);
|
| 299 |
planeGeo.rotateX(-Math.PI / 2);
|
| 300 |
+
placementPlane = new THREE.Mesh(planeGeo, new THREE.MeshStandardMaterial({ color: 0x444444, transparent: true, opacity: 0.5 }));
|
| 301 |
+
placementPlane.receiveShadow = true;
|
| 302 |
scene.add(placementPlane);
|
| 303 |
|
| 304 |
raycaster = new THREE.Raycaster();
|
| 305 |
mouse = new THREE.Vector2();
|
| 306 |
+
|
| 307 |
+
transformControls = new TransformControls(camera, renderer.domElement);
|
| 308 |
+
transformControls.addEventListener('dragging-changed', event => { orbitControls.enabled = !event.value; });
|
| 309 |
+
transformControls.addEventListener('mouseUp', () => updatePlacementFromTransform());
|
| 310 |
+
transformControls.setMode('translate');
|
| 311 |
+
scene.add(transformControls);
|
| 312 |
|
| 313 |
initUI();
|
| 314 |
initPlayer();
|
| 315 |
initJoystick();
|
|
|
|
| 316 |
|
| 317 |
window.addEventListener('resize', onWindowResize);
|
| 318 |
renderer.domElement.addEventListener('pointermove', onPointerMove);
|
| 319 |
renderer.domElement.addEventListener('pointerdown', onPointerDown);
|
| 320 |
+
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
| 321 |
window.addEventListener('keyup', e => { keyStates[e.code] = false; });
|
| 322 |
renderer.domElement.addEventListener('wheel', e => {
|
| 323 |
+
if (!isPlayMode && !e.ctrlKey) {
|
| 324 |
e.preventDefault();
|
| 325 |
+
rotatePreview(e.deltaY > 0 ? 1 : -1);
|
|
|
|
|
|
|
| 326 |
}
|
| 327 |
}, { passive: false });
|
| 328 |
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
|
|
|
| 330 |
animate();
|
| 331 |
}
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
function initPlayer() {
|
| 334 |
+
const playerGeo = new THREE.CylinderGeometry(0.35, 0.35, 1.8, 16);
|
| 335 |
+
playerGeo.translate(0, 0.9, 0);
|
| 336 |
const playerMat = new THREE.MeshStandardMaterial({ color: 0x00aaff });
|
| 337 |
player = new THREE.Mesh(playerGeo, playerMat);
|
| 338 |
player.castShadow = true;
|
|
|
|
| 341 |
}
|
| 342 |
|
| 343 |
function initUI() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
document.getElementById('save-project').addEventListener('click', saveProject);
|
| 345 |
document.getElementById('load-project').addEventListener('click', loadProject);
|
| 346 |
document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
|
| 347 |
document.getElementById('clear-level').addEventListener('click', clearLevel);
|
| 348 |
document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open'));
|
| 349 |
document.getElementById('project-list').addEventListener('change', e => document.getElementById('project-name').value = e.target.value);
|
| 350 |
+
document.getElementById('add-asset-btn').addEventListener('click', openAssetModal);
|
| 351 |
+
document.getElementById('save-asset-btn').addEventListener('click', saveAsset);
|
| 352 |
+
document.getElementById('cancel-asset-btn').addEventListener('click', closeAssetModal);
|
| 353 |
+
document.getElementById('upload-texture-btn').addEventListener('click', uploadTexture);
|
| 354 |
+
|
| 355 |
+
const toolSelector = document.createElement('div');
|
| 356 |
+
toolSelector.className = 'tool-item';
|
| 357 |
+
toolSelector.textContent = `Select / Move`;
|
| 358 |
+
toolSelector.dataset.toolId = 'select';
|
| 359 |
+
toolSelector.addEventListener('click', () => selectTool('select'));
|
| 360 |
+
document.getElementById('tool-selector').appendChild(toolSelector);
|
| 361 |
+
|
| 362 |
+
updateToolSelector();
|
| 363 |
}
|
| 364 |
+
|
| 365 |
function initJoystick() {
|
| 366 |
const joystickContainer = document.getElementById('joystick-container');
|
| 367 |
const joystickHandle = document.getElementById('joystick-handle');
|
| 368 |
const maxRadius = joystickContainer.clientWidth / 2;
|
| 369 |
|
| 370 |
function onTouchStart(event) {
|
| 371 |
+
if(!isPlayMode) return;
|
| 372 |
const touch = event.touches[0];
|
| 373 |
joystick.active = true;
|
| 374 |
joystick.center.set(touch.clientX, touch.clientY);
|
| 375 |
+
joystickContainer.style.left = `${touch.clientX - maxRadius}px`;
|
| 376 |
+
joystickContainer.style.top = `${touch.clientY - maxRadius}px`;
|
| 377 |
}
|
| 378 |
function onTouchMove(event) {
|
| 379 |
if (!joystick.active) return;
|
|
|
|
| 388 |
joystick.vector.set(0, 0);
|
| 389 |
joystickHandle.style.transform = 'translate(0, 0)';
|
| 390 |
}
|
| 391 |
+
window.addEventListener('touchstart', onTouchStart, { passive: false });
|
| 392 |
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
| 393 |
window.addEventListener('touchend', onTouchEnd);
|
| 394 |
}
|
| 395 |
+
|
| 396 |
+
function selectTool(toolId) {
|
| 397 |
+
currentToolId = toolId;
|
| 398 |
document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
|
| 399 |
+
const activeEl = document.querySelector(`.tool-item[data-tool-id="${toolId}"]`);
|
| 400 |
if (activeEl) activeEl.classList.add('active');
|
| 401 |
|
| 402 |
+
if (previewMesh) {
|
| 403 |
+
scene.remove(previewMesh);
|
| 404 |
+
previewMesh = null;
|
| 405 |
+
}
|
| 406 |
+
transformControls.detach();
|
| 407 |
+
|
| 408 |
+
if (toolId !== 'select' && levelData.assets[toolId]) {
|
| 409 |
+
const asset = levelData.assets[toolId];
|
| 410 |
+
const geometry = new THREE.BoxGeometry(asset.w, asset.h, asset.d);
|
| 411 |
+
const material = new THREE.MeshBasicMaterial({ color: 0x00aaff, transparent: true, opacity: 0.6, wireframe: true });
|
| 412 |
+
previewMesh = new THREE.Mesh(geometry, material);
|
| 413 |
+
scene.add(previewMesh);
|
| 414 |
}
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
function updateToolSelector() {
|
| 418 |
+
const toolSelector = document.getElementById('tool-selector');
|
| 419 |
+
toolSelector.innerHTML = '';
|
| 420 |
+
|
| 421 |
+
const selectToolEl = document.createElement('div');
|
| 422 |
+
selectToolEl.className = 'tool-item';
|
| 423 |
+
selectToolEl.textContent = `Select / Move`;
|
| 424 |
+
selectToolEl.dataset.toolId = 'select';
|
| 425 |
+
selectToolEl.addEventListener('click', () => selectTool('select'));
|
| 426 |
+
toolSelector.appendChild(selectToolEl);
|
| 427 |
+
|
| 428 |
+
for (const assetId in levelData.assets) {
|
| 429 |
+
const asset = levelData.assets[assetId];
|
| 430 |
+
const item = document.createElement('div');
|
| 431 |
+
item.className = 'tool-item';
|
| 432 |
+
item.textContent = `${assetId}`;
|
| 433 |
+
item.dataset.toolId = assetId;
|
| 434 |
+
item.addEventListener('click', () => selectTool(assetId));
|
| 435 |
+
toolSelector.appendChild(item);
|
| 436 |
+
}
|
| 437 |
+
selectTool(currentToolId || 'select');
|
| 438 |
}
|
| 439 |
|
| 440 |
function onWindowResize() {
|
| 441 |
const aspect = window.innerWidth / window.innerHeight;
|
| 442 |
+
const frustumSize = 30;
|
| 443 |
camera.left = frustumSize * aspect / -2;
|
| 444 |
camera.right = frustumSize * aspect / 2;
|
| 445 |
camera.top = frustumSize / 2;
|
|
|
|
| 449 |
}
|
| 450 |
|
| 451 |
function onPointerMove(event) {
|
| 452 |
+
if (isPlayMode || transformControls.dragging) return;
|
| 453 |
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
|
| 454 |
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
|
| 455 |
+
|
| 456 |
+
if (previewMesh) {
|
| 457 |
+
raycaster.setFromCamera(mouse, camera);
|
| 458 |
+
const intersects = raycaster.intersectObject(placementPlane);
|
| 459 |
+
if (intersects.length > 0) {
|
| 460 |
+
const point = intersects[0].point;
|
| 461 |
+
const gridX = Math.round(point.x / gridSize) * gridSize;
|
| 462 |
+
const gridZ = Math.round(point.z / gridSize) * gridSize;
|
| 463 |
+
|
| 464 |
+
const asset = levelData.assets[currentToolId];
|
| 465 |
+
if(asset) {
|
| 466 |
+
previewMesh.position.set(gridX, asset.h / 2, gridZ);
|
| 467 |
+
previewMesh.visible = true;
|
| 468 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
} else {
|
| 470 |
+
previewMesh.visible = false;
|
| 471 |
}
|
|
|
|
|
|
|
|
|
|
| 472 |
}
|
| 473 |
}
|
| 474 |
|
| 475 |
function handleKeyDown(event) {
|
| 476 |
+
keyStates[event.code] = true;
|
| 477 |
if (isPlayMode) return;
|
| 478 |
+
|
| 479 |
if (event.code === 'KeyR') {
|
| 480 |
rotatePreview(1);
|
| 481 |
}
|
| 482 |
+
if (event.code === 'Delete' && transformControls.object) {
|
| 483 |
+
const selectedObject = transformControls.object;
|
| 484 |
+
const placementId = selectedObject.userData.placementId;
|
| 485 |
+
|
| 486 |
+
transformControls.detach();
|
| 487 |
+
scene.remove(selectedObject);
|
| 488 |
+
placedObjects.delete(placementId);
|
| 489 |
+
levelData.placements = levelData.placements.filter(p => p.id !== placementId);
|
| 490 |
+
updateCollisionBoxes();
|
| 491 |
+
}
|
| 492 |
+
if(event.code === 'Escape') {
|
| 493 |
+
transformControls.detach();
|
| 494 |
+
selectTool('select');
|
| 495 |
+
}
|
| 496 |
}
|
| 497 |
|
| 498 |
function onPointerDown(event) {
|
| 499 |
+
if (isPlayMode || event.target.tagName !== 'CANVAS') return;
|
| 500 |
+
if (event.button === 2) {
|
| 501 |
+
rotatePreview(1);
|
|
|
|
|
|
|
|
|
|
| 502 |
return;
|
| 503 |
}
|
| 504 |
+
if (event.button !== 0) return;
|
| 505 |
|
| 506 |
+
raycaster.setFromCamera(mouse, camera);
|
|
|
|
| 507 |
|
| 508 |
+
if (currentToolId === 'select') {
|
| 509 |
+
const placedMeshes = Array.from(placedObjects.values());
|
| 510 |
+
const intersects = raycaster.intersectObjects(placedMeshes, false);
|
| 511 |
+
if (intersects.length > 0) {
|
| 512 |
+
transformControls.attach(intersects[0].object);
|
| 513 |
+
} else {
|
| 514 |
+
transformControls.detach();
|
| 515 |
+
}
|
| 516 |
+
} else if (previewMesh && previewMesh.visible) {
|
| 517 |
+
addItem(previewMesh.position, currentRotation);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
}
|
| 519 |
}
|
| 520 |
|
| 521 |
function rotatePreview(direction) {
|
| 522 |
+
if (transformControls.object) {
|
| 523 |
+
transformControls.object.rotation.y += (Math.PI / 2) * direction;
|
| 524 |
+
updatePlacementFromTransform();
|
| 525 |
+
} else if (previewMesh) {
|
| 526 |
+
currentRotation += (Math.PI / 2) * direction;
|
| 527 |
+
previewMesh.rotation.y = currentRotation;
|
| 528 |
+
}
|
| 529 |
}
|
| 530 |
|
| 531 |
function addItem(pos, rotation) {
|
| 532 |
+
const assetId = currentToolId;
|
| 533 |
+
if (!assetId || !levelData.assets[assetId]) return;
|
| 534 |
|
| 535 |
+
const placement = {
|
| 536 |
+
id: THREE.MathUtils.generateUUID(),
|
| 537 |
+
assetId: assetId,
|
| 538 |
+
x: pos.x, y: pos.y, z: pos.z,
|
| 539 |
+
rotY: rotation
|
| 540 |
+
};
|
| 541 |
+
levelData.placements.push(placement);
|
| 542 |
+
createObjectForPlacement(placement);
|
| 543 |
+
updateCollisionBoxes();
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
function createObjectForPlacement(placement) {
|
| 547 |
+
const asset = levelData.assets[placement.assetId];
|
| 548 |
+
if (!asset) return null;
|
| 549 |
|
| 550 |
+
const geometry = new THREE.BoxGeometry(asset.w, asset.h, asset.d);
|
| 551 |
+
const texture = loadTexture(asset.texture);
|
| 552 |
+
texture.repeat.set(asset.w, asset.h);
|
| 553 |
+
const material = new THREE.MeshStandardMaterial({ map: texture });
|
| 554 |
+
|
| 555 |
+
const mesh = new THREE.Mesh(geometry, material);
|
| 556 |
+
mesh.position.set(placement.x, placement.y, placement.z);
|
| 557 |
+
mesh.rotation.y = placement.rotY;
|
| 558 |
+
mesh.castShadow = true;
|
| 559 |
+
mesh.receiveShadow = true;
|
| 560 |
+
mesh.userData.placementId = placement.id;
|
| 561 |
+
|
| 562 |
+
scene.add(mesh);
|
| 563 |
+
placedObjects.set(placement.id, mesh);
|
| 564 |
+
return mesh;
|
| 565 |
}
|
| 566 |
|
| 567 |
+
function updatePlacementFromTransform() {
|
| 568 |
+
const obj = transformControls.object;
|
| 569 |
+
if (!obj) return;
|
| 570 |
+
const placement = levelData.placements.find(p => p.id === obj.userData.placementId);
|
| 571 |
+
if(placement) {
|
| 572 |
+
placement.x = obj.position.x;
|
| 573 |
+
placement.y = obj.position.y;
|
| 574 |
+
placement.z = obj.position.z;
|
| 575 |
+
placement.rotY = obj.rotation.y;
|
| 576 |
+
updateCollisionBoxes();
|
| 577 |
}
|
| 578 |
}
|
| 579 |
+
|
| 580 |
+
function rebuildLevel() {
|
| 581 |
+
clearPlacedObjects();
|
| 582 |
+
levelData.placements.forEach(p => createObjectForPlacement(p));
|
| 583 |
+
updateCollisionBoxes();
|
| 584 |
+
}
|
| 585 |
|
| 586 |
+
function updateCollisionBoxes() {
|
| 587 |
+
collisionBBoxes = [];
|
| 588 |
+
placedObjects.forEach(mesh => {
|
| 589 |
+
const asset = levelData.assets[levelData.placements.find(p => p.id === mesh.userData.placementId).assetId];
|
| 590 |
+
if (asset && asset.solid) {
|
| 591 |
+
mesh.updateWorldMatrix(true, false);
|
| 592 |
+
const box = new THREE.Box3().setFromObject(mesh);
|
| 593 |
+
collisionBBoxes.push(box);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
}
|
| 595 |
+
});
|
| 596 |
}
|
| 597 |
|
| 598 |
function clearLevel() {
|
| 599 |
if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
|
| 600 |
+
levelData.placements = [];
|
| 601 |
+
clearPlacedObjects();
|
| 602 |
+
updateCollisionBoxes();
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
function clearPlacedObjects() {
|
| 606 |
+
placedObjects.forEach(obj => scene.remove(obj));
|
| 607 |
+
placedObjects.clear();
|
| 608 |
+
transformControls.detach();
|
| 609 |
}
|
| 610 |
|
| 611 |
function togglePlayMode() {
|
|
|
|
| 615 |
|
| 616 |
if (isPlayMode) {
|
| 617 |
uiContainer.style.display = 'none';
|
| 618 |
+
transformControls.detach();
|
| 619 |
+
transformControls.enabled = false;
|
| 620 |
+
if(previewMesh) previewMesh.visible = false;
|
| 621 |
+
orbitControls.enabled = false;
|
| 622 |
player.visible = true;
|
| 623 |
player.position.set(0, 0, 0);
|
| 624 |
+
if ('ontouchstart' in window) {
|
| 625 |
+
joystickContainer.style.display = 'block';
|
| 626 |
+
}
|
| 627 |
} else {
|
| 628 |
uiContainer.style.display = 'flex';
|
| 629 |
+
transformControls.enabled = true;
|
| 630 |
+
if(previewMesh) previewMesh.visible = true;
|
| 631 |
+
orbitControls.enabled = true;
|
| 632 |
player.visible = false;
|
| 633 |
joystickContainer.style.display = 'none';
|
| 634 |
+
camera.position.set(50, 50, 50);
|
| 635 |
camera.lookAt(0,0,0);
|
| 636 |
+
orbitControls.target.set(0, 0, 0);
|
|
|
|
| 637 |
}
|
| 638 |
}
|
| 639 |
+
|
| 640 |
async function saveProject() {
|
| 641 |
const projectName = document.getElementById('project-name').value.trim();
|
| 642 |
if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
|
|
|
|
| 649 |
});
|
| 650 |
if (!response.ok) throw new Error('Ошибка при сохранении проекта.');
|
| 651 |
alert(`Проект '${projectName}' успешно сохранен!`);
|
| 652 |
+
updateProjectList(projectName);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
} catch (error) {
|
| 654 |
alert(`Не удалось сохранить проект: ${error.message}`);
|
| 655 |
} finally {
|
|
|
|
| 663 |
showSpinner(true);
|
| 664 |
try {
|
| 665 |
const response = await fetch(`/api/project/${projectName}`);
|
|
|
|
| 666 |
if (!response.ok) throw new Error('Проект не найден или ошибка загрузки.');
|
| 667 |
+
const result = await response.json();
|
| 668 |
|
| 669 |
+
clearPlacedObjects();
|
| 670 |
+
levelData = result.data;
|
| 671 |
+
if (!levelData.assets) levelData.assets = {};
|
| 672 |
+
if (!levelData.placements) levelData.placements = [];
|
| 673 |
+
|
| 674 |
+
rebuildLevel();
|
| 675 |
+
updateToolSelector();
|
| 676 |
document.getElementById('project-name').value = projectName;
|
| 677 |
|
| 678 |
} catch (error) {
|
|
|
|
| 681 |
showSpinner(false);
|
| 682 |
}
|
| 683 |
}
|
| 684 |
+
|
| 685 |
+
function updateProjectList(newProjectName) {
|
| 686 |
+
const projectList = document.getElementById('project-list');
|
| 687 |
+
if (![...projectList.options].some(opt => opt.value === newProjectName)) {
|
| 688 |
+
const newOption = document.createElement('option');
|
| 689 |
+
newOption.value = newOption.textContent = newProjectName;
|
| 690 |
+
projectList.appendChild(newOption);
|
| 691 |
+
projectList.value = newProjectName;
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
function openAssetModal() {
|
| 696 |
+
document.getElementById('modal-backdrop').style.display = 'flex';
|
| 697 |
+
}
|
| 698 |
+
function closeAssetModal() {
|
| 699 |
+
document.getElementById('modal-backdrop').style.display = 'none';
|
| 700 |
+
}
|
| 701 |
|
| 702 |
+
function saveAsset() {
|
| 703 |
+
const name = document.getElementById('asset-name').value.trim();
|
| 704 |
+
if (!name) { alert('Имя объекта не может быть пустым.'); return; }
|
| 705 |
+
if (levelData.assets[name]) { alert('Объект с таким именем уже существует.'); return; }
|
| 706 |
+
|
| 707 |
+
levelData.assets[name] = {
|
| 708 |
+
texture: document.getElementById('asset-texture').value,
|
| 709 |
+
w: parseFloat(document.getElementById('asset-width').value) || 1,
|
| 710 |
+
h: parseFloat(document.getElementById('asset-height').value) || 1,
|
| 711 |
+
d: parseFloat(document.getElementById('asset-depth').value) || 1,
|
| 712 |
+
solid: document.getElementById('asset-solid').checked
|
| 713 |
+
};
|
| 714 |
+
|
| 715 |
+
updateToolSelector();
|
| 716 |
+
closeAssetModal();
|
| 717 |
+
}
|
| 718 |
|
| 719 |
+
async function uploadTexture() {
|
| 720 |
+
const fileInput = document.getElementById('texture-upload');
|
| 721 |
+
if (fileInput.files.length === 0) {
|
| 722 |
+
alert('Пожалуйста, выберите файл текстуры.'); return;
|
| 723 |
+
}
|
| 724 |
+
const file = fileInput.files[0];
|
| 725 |
+
const formData = new FormData();
|
| 726 |
+
formData.append('texture', file);
|
| 727 |
+
showSpinner(true);
|
| 728 |
+
try {
|
| 729 |
+
const response = await fetch('/api/texture', { method: 'POST', body: formData });
|
| 730 |
+
if(!response.ok) throw new Error('Ошибка загрузки текстуры на сервер.');
|
| 731 |
+
const result = await response.json();
|
| 732 |
+
|
| 733 |
+
const textureSelect = document.getElementById('asset-texture');
|
| 734 |
+
const newOption = document.createElement('option');
|
| 735 |
+
newOption.value = newOption.textContent = result.filename;
|
| 736 |
+
textureSelect.appendChild(newOption);
|
| 737 |
+
textureSelect.value = result.filename;
|
| 738 |
+
alert('Текстура успешно загружена!');
|
| 739 |
+
|
| 740 |
+
} catch(error) {
|
| 741 |
+
alert(`Не удалось загрузить текстуру: ${error.message}`);
|
| 742 |
+
} finally {
|
| 743 |
+
showSpinner(false);
|
| 744 |
+
}
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
const moveDirection = new THREE.Vector3();
|
| 748 |
function updatePlayer(deltaTime) {
|
| 749 |
const speed = playerSpeed * deltaTime;
|
| 750 |
moveDirection.set(0,0,0);
|
|
|
|
| 767 |
const intendedXPos = player.position.clone().add(new THREE.Vector3(moveDirection.x, 0, 0));
|
| 768 |
const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0));
|
| 769 |
let collisionX = false;
|
| 770 |
+
for(const wallBox of collisionBBoxes) {
|
| 771 |
if (playerBoxX.intersectsBox(wallBox)) {
|
| 772 |
collisionX = true;
|
| 773 |
break;
|
|
|
|
| 777 |
|
| 778 |
const intendedZPos = player.position.clone().add(new THREE.Vector3(0, 0, moveDirection.z));
|
| 779 |
const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z));
|
| 780 |
+
let collisionZ = false;
|
| 781 |
+
for(const wallBox of collisionBBoxes) {
|
| 782 |
if (playerBoxZ.intersectsBox(wallBox)) {
|
| 783 |
collisionZ = true;
|
| 784 |
break;
|
|
|
|
| 786 |
}
|
| 787 |
if(!collisionZ) player.position.z = intendedZPos.z;
|
| 788 |
|
| 789 |
+
camera.position.x = player.position.x + 50;
|
| 790 |
+
camera.position.z = player.position.z + 50;
|
| 791 |
+
camera.position.y = 50;
|
| 792 |
camera.lookAt(player.position);
|
|
|
|
| 793 |
}
|
| 794 |
|
| 795 |
function showSpinner(show) {
|
|
|
|
| 802 |
|
| 803 |
if (isPlayMode) {
|
| 804 |
updatePlayer(deltaTime);
|
| 805 |
+
} else {
|
| 806 |
+
orbitControls.update();
|
| 807 |
}
|
|
|
|
| 808 |
renderer.render(scene, camera);
|
| 809 |
}
|
| 810 |
|
|
|
|
| 816 |
|
| 817 |
@app.route('/')
|
| 818 |
def editor():
|
| 819 |
+
projects = [p.replace('.json', '') for p in list_files_from_hf('pz_projects')]
|
| 820 |
+
textures = list_files_from_hf('pz_textures')
|
| 821 |
+
return render_template_string(EDITOR_TEMPLATE, projects=projects, textures=textures)
|
| 822 |
|
| 823 |
@app.route('/api/project', methods=['POST'])
|
| 824 |
def save_project_api():
|
|
|
|
| 828 |
return jsonify({"error": "Project name is required"}), 400
|
| 829 |
|
| 830 |
local_filename = f"{uuid4().hex}.json"
|
| 831 |
+
local_path = os.path.join(app.config['UPLOAD_FOLDER'], local_filename)
|
| 832 |
+
with open(local_path, 'w', encoding='utf-8') as f:
|
| 833 |
json.dump(data, f)
|
| 834 |
|
| 835 |
+
success = upload_to_hf(local_path, f"pz_projects/{project_name}.json", f"Save PZ project {project_name}")
|
| 836 |
|
| 837 |
+
if os.path.exists(local_path):
|
| 838 |
+
os.remove(local_path)
|
| 839 |
|
| 840 |
if success:
|
| 841 |
return jsonify({"message": "Project saved successfully"}), 201
|
|
|
|
| 844 |
|
| 845 |
@app.route('/api/project/<project_name>', methods=['GET'])
|
| 846 |
def load_project_api(project_name):
|
| 847 |
+
local_path = download_from_hf(f"pz_projects/{project_name}.json")
|
| 848 |
+
if local_path:
|
| 849 |
+
with open(local_path, 'r', encoding='utf-8') as f:
|
| 850 |
+
project_data = json.load(f)
|
| 851 |
+
os.remove(local_path)
|
| 852 |
return jsonify(project_data)
|
| 853 |
else:
|
| 854 |
return jsonify({"error": "Project not found or failed to download"}), 404
|
| 855 |
|
| 856 |
+
@app.route('/api/texture', methods=['POST'])
|
| 857 |
+
def upload_texture_api():
|
| 858 |
+
if 'texture' not in request.files:
|
| 859 |
+
return jsonify({"error": "No texture file provided"}), 400
|
| 860 |
+
file = request.files['texture']
|
| 861 |
+
if file.filename == '':
|
| 862 |
+
return jsonify({"error": "No selected file"}), 400
|
| 863 |
+
|
| 864 |
+
filename = secure_filename(file.filename)
|
| 865 |
+
local_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
| 866 |
+
file.save(local_path)
|
| 867 |
+
|
| 868 |
+
success = upload_to_hf(local_path, f"pz_textures/{filename}", f"Upload texture {filename}")
|
| 869 |
+
|
| 870 |
+
if os.path.exists(local_path):
|
| 871 |
+
os.remove(local_path)
|
| 872 |
+
|
| 873 |
+
if success:
|
| 874 |
+
return jsonify({"message": "Texture uploaded", "filename": filename}), 201
|
| 875 |
+
else:
|
| 876 |
+
return jsonify({"error": "Failed to upload texture"}), 500
|
| 877 |
+
|
| 878 |
+
@app.route('/api/texture/<filename>')
|
| 879 |
+
def get_texture(filename):
|
| 880 |
+
local_path = download_from_hf(f"pz_textures/{filename}")
|
| 881 |
+
if local_path:
|
| 882 |
+
# Since send_from_directory needs a directory and a filename, we move the downloaded file
|
| 883 |
+
temp_dir = os.path.join(app.config['UPLOAD_FOLDER'], 'textures')
|
| 884 |
+
os.makedirs(temp_dir, exist_ok=True)
|
| 885 |
+
final_path = os.path.join(temp_dir, os.path.basename(local_path))
|
| 886 |
+
os.rename(local_path, final_path)
|
| 887 |
+
return send_from_directory(temp_dir, os.path.basename(local_path))
|
| 888 |
+
return jsonify({"error": "Texture not found"}), 404
|
| 889 |
+
|
| 890 |
if __name__ == '__main__':
|
| 891 |
port = int(os.environ.get('PORT', 7860))
|
| 892 |
app.run(debug=False, host='0.0.0.0', port=port)
|