Kgshop commited on
Commit
1a90ba5
·
verified ·
1 Parent(s): fa75b3d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +459 -327
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 upload_project_to_hf(local_path, project_name):
22
- if not HF_TOKEN_WRITE:
 
 
 
 
 
 
 
 
23
  return False
24
  try:
25
- api = HfApi()
26
  api.upload_file(
27
  path_or_fileobj=local_path,
28
- path_in_repo=f"pz_projects/{project_name}.json",
29
  repo_id=REPO_ID,
30
  repo_type="dataset",
31
- token=HF_TOKEN_WRITE,
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 {project_name} to HF: {e}")
37
  return False
38
 
39
- def download_project_from_hf(project_name):
40
- token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
41
- if not token_to_use:
42
  return None
43
  try:
44
  local_path = hf_hub_download(
45
  repo_id=REPO_ID,
46
- filename=f"pz_projects/{project_name}.json",
47
  repo_type="dataset",
48
- token=token_to_use,
49
  local_dir=".",
50
  local_dir_use_symlinks=False,
51
  force_download=True
52
  )
53
- with open(local_path, 'r', encoding='utf-8') as f:
54
- data = json.load(f)
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 as e:
61
  return None
62
 
63
- def list_projects_from_hf():
64
- token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
65
- if not token_to_use:
66
  return []
67
  try:
68
- api = HfApi()
69
- repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset", token=token_to_use)
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.85);
88
- backdrop-filter: blur(5px);
89
- padding: 15px;
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: 5px;
105
  }
 
106
  button {
107
- width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px; transition: background-color 0.2s;
 
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: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; }
115
  .tool-item {
116
- padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align: center; cursor: pointer;
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: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
123
- border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
124
- width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 100;
 
 
 
 
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: absolute; bottom: 30px; left: 30px; width: 120px; height: 120px; background: rgba(128, 128, 128, 0.3); border-radius: 50%;
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
- <div class="ui-group">
 
 
 
 
 
168
  <h3>Инструменты</h3>
169
  <div id="tool-selector"></div>
170
- <p style="font-size: 0.8em; color: #888; margin-top: 10px;">
171
- ЛКМ: Разместить<br>
172
- ПКМ: Вращать / Двигать камеру<br>
173
- Shift + ЛКМ: Удалить<br>
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, raycaster, mouse, placementPlane, gridHelper, previewMesh;
 
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 wallBBoxes = [];
197
 
198
- let currentTool = { category: 'floors', type: 'grass' };
199
  let currentRotation = 0;
200
  const gridSize = 1;
201
- const levelData = { floors: {}, walls: {}, objects: {} };
 
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 loadedTextures = {};
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
- const ASSETS = {
222
- floors: {
223
- grass: { size: [1, 0.1, 1], mapUrl: 'https://i.ibb.co/FqsRw2T/grass.png' },
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
- const instancedMeshes = {};
241
- let isPanning = false;
242
- const panStart = new THREE.Vector2();
 
 
243
 
244
  function init() {
245
  scene = new THREE.Scene();
246
- scene.background = new THREE.Color(0x3d4a55);
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(25, 30, 25);
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
- const hemiLight = new THREE.HemisphereLight(0xB1E1FF, 0x495057, 0.6);
 
 
 
 
 
 
 
 
263
  scene.add(hemiLight);
264
 
265
- const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
266
  dirLight.position.set(-30, 50, -30);
267
  dirLight.castShadow = true;
268
- dirLight.shadow.mapSize.width = 2048;
269
- dirLight.shadow.mapSize.height = 2048;
270
- dirLight.shadow.camera.left = -50;
271
- dirLight.shadow.camera.right = 50;
272
- dirLight.shadow.camera.top = 50;
273
- dirLight.shadow.camera.bottom = -50;
274
  scene.add(dirLight);
275
 
276
- gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x444444);
277
  scene.add(gridHelper);
278
 
279
- const planeGeo = new THREE.PlaneGeometry(100, 100);
280
  planeGeo.rotateX(-Math.PI / 2);
281
- placementPlane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ visible: false }));
 
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
- renderer.domElement.addEventListener('pointerup', onPointerUp);
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
- const zoomAmount = e.deltaY > 0 ? 1.1 : 0.9;
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.25, 0.25, 1.5, 16);
339
- playerGeo.translate(0, 0.75, 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
- joystickContainer.addEventListener('touchstart', onTouchStart, { passive: true });
395
  window.addEventListener('touchmove', onTouchMove, { passive: true });
396
  window.addEventListener('touchend', onTouchEnd);
397
  }
398
-
399
- function selectTool(category, type) {
400
- currentTool = { category, type };
401
  document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
402
- const activeEl = document.querySelector(`.tool-item[data-category="${category}"][data-type="${type}"]`);
403
  if (activeEl) activeEl.classList.add('active');
404
 
405
- if (previewMesh) scene.remove(previewMesh);
406
- const asset = ASSETS[category][type];
407
- let geometry;
408
- if(asset.geometry === 'cylinder') {
409
- geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
410
- } else {
411
- geometry = new THREE.BoxGeometry(...asset.size);
 
 
 
 
 
412
  }
413
- const material = new THREE.MeshBasicMaterial({ map: loadTexture(asset.mapUrl), transparent: true, opacity: 0.6 });
414
- if(asset.repeat) material.map.repeat.set(...asset.repeat);
415
-
416
- previewMesh = new THREE.Mesh(geometry, material);
417
- scene.add(previewMesh);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  }
419
 
420
  function onWindowResize() {
421
  const aspect = window.innerWidth / window.innerHeight;
422
- const frustumSize = 30 / camera.zoom;
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(isPanning) {
436
- const dx = event.clientX - panStart.x;
437
- const dy = event.clientY - panStart.y;
438
- panStart.set(event.clientX, event.clientY);
439
- const panSpeed = (camera.right - camera.left) / (camera.zoom * renderer.domElement.clientWidth);
440
- camera.position.x -= dx * panSpeed;
441
- camera.position.z -= dy * panSpeed * Math.cos(camera.rotation.x);
442
- return;
443
- }
444
-
445
- if (isPlayMode) return;
446
- raycaster.setFromCamera(mouse, camera);
447
- const intersects = raycaster.intersectObject(placementPlane);
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.position.y = asset.size[1] / 2;
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 && event.target.tagName === 'CANVAS') {
485
- if(isMobile) return;
486
- }
487
- if (!isPlayMode && event.button === 2) {
488
- isPanning = true;
489
- panStart.set(event.clientX, event.clientY);
490
  return;
491
  }
 
492
 
493
- if (isPlayMode || !previewMesh.visible || event.button !== 0) return;
494
- const isRemoving = event.shiftKey;
495
 
496
- const pos = previewMesh.position;
497
- const key = `${pos.x},${pos.y},${pos.z}`;
498
-
499
- if (isRemoving) {
500
- removeItemAt(pos);
501
- } else {
502
- addItem(pos, currentRotation);
503
- }
504
- updateLevelGeometry();
505
- }
506
-
507
- function onPointerUp(event) {
508
- if (event.button === 2) {
509
- isPanning = false;
510
  }
511
  }
512
 
513
  function rotatePreview(direction) {
514
- currentRotation += (Math.PI / 2) * direction;
515
- previewMesh.rotation.y = currentRotation;
 
 
 
 
 
516
  }
517
 
518
  function addItem(pos, rotation) {
519
- const { category, type } = currentTool;
520
- const key = `${pos.x},${pos.z}`;
521
 
522
- removeItemAt(pos);
 
 
 
 
 
 
 
 
 
 
 
 
 
523
 
524
- if (!levelData[category]) levelData[category] = {};
525
- if (!levelData[category][type]) levelData[category][type] = [];
526
- levelData[category][type].push([pos.x, pos.z, rotation]);
 
 
 
 
 
 
 
 
 
 
 
 
527
  }
528
 
529
- function removeItemAt(pos) {
530
- const keyToRemove = `${pos.x},${pos.z}`;
531
- for (const category in levelData) {
532
- for (const type in levelData[category]) {
533
- levelData[category][type] = levelData[category][type].filter(item => {
534
- const itemKey = `${item[0]},${item[1]}`;
535
- return itemKey !== keyToRemove;
536
- });
537
- }
 
538
  }
539
  }
 
 
 
 
 
 
540
 
541
- function updateLevelGeometry() {
542
- const dummy = new THREE.Object3D();
543
- wallBBoxes = [];
544
-
545
- for (const category in ASSETS) {
546
- for (const type in ASSETS[category]) {
547
- const mesh = instancedMeshes[category][type];
548
- const dataArray = levelData[category]?.[type] || [];
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.floors = {};
578
- levelData.walls = {};
579
- levelData.objects = {};
580
- updateLevelGeometry();
 
 
 
 
 
581
  }
582
 
583
  function togglePlayMode() {
@@ -587,24 +615,28 @@ EDITOR_TEMPLATE = '''
587
 
588
  if (isPlayMode) {
589
  uiContainer.style.display = 'none';
590
- gridHelper.visible = false;
591
- previewMesh.visible = false;
 
 
592
  player.visible = true;
593
  player.position.set(0, 0, 0);
594
- if(isMobile) joystickContainer.style.display = 'block';
 
 
595
  } else {
596
  uiContainer.style.display = 'flex';
597
- gridHelper.visible = true;
598
- previewMesh.visible = true;
 
599
  player.visible = false;
600
  joystickContainer.style.display = 'none';
601
- camera.position.set(25, 30, 25);
602
  camera.lookAt(0,0,0);
603
- camera.zoom = 1;
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
- const projectList = document.getElementById('project-list');
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
- clearLevel();
643
- Object.assign(levelData, result.data);
644
- updateLevelGeometry();
 
 
 
 
645
  document.getElementById('project-name').value = projectName;
646
 
647
  } catch (error) {
@@ -650,10 +681,70 @@ EDITOR_TEMPLATE = '''
650
  showSpinner(false);
651
  }
652
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
 
654
- const moveDirection = new THREE.Vector3();
655
- const cameraOffset = new THREE.Vector3(20, 25, 20);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 wallBBoxes) {
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
- let collisionZ = false;
690
- for(const wallBox of wallBBoxes) {
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.copy(player.position).add(cameraOffset);
 
 
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 = list_projects_from_hf()
727
- return render_template_string(EDITOR_TEMPLATE, projects=projects)
 
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
- with open(local_filename, 'w', encoding='utf-8') as f:
 
738
  json.dump(data, f)
739
 
740
- success = upload_project_to_hf(local_filename, project_name)
741
 
742
- if os.path.exists(local_filename):
743
- os.remove(local_filename)
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
- project_data = download_project_from_hf(project_name)
753
- if project_data:
 
 
 
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)