Kgshop commited on
Commit
7ddbaaa
·
verified ·
1 Parent(s): 9fe9010

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +382 -79
app.py CHANGED
@@ -100,7 +100,7 @@ EDITOR_TEMPLATE = '''
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 {
@@ -114,7 +114,7 @@ EDITOR_TEMPLATE = '''
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; }
@@ -123,9 +123,9 @@ EDITOR_TEMPLATE = '''
123
  #loading-spinner {
124
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
125
  border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
126
- width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 100;
127
  }
128
- #blocker { position: fixed; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; top:0; left:0; z-index: 99; }
129
  #instructions { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; font-size: 14px; cursor: pointer; color: white; }
130
  #burger-menu {
131
  position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
@@ -137,6 +137,18 @@ EDITOR_TEMPLATE = '''
137
  display: none; z-index: 100; pointer-events: auto; user-select: none;
138
  }
139
  #joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
 
 
 
 
 
 
 
 
 
 
 
 
140
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
141
  @media (max-width: 800px) {
142
  #ui-panel { transform: translateX(-100%); padding-top: 60px; }
@@ -176,6 +188,10 @@ EDITOR_TEMPLATE = '''
176
  <button id="draw-mode-area" class="tool-item">Область</button>
177
  </div>
178
  <div id="tool-selector" class="tool-selector"></div>
 
 
 
 
179
  <p style="font-size: 0.8em; color: #888; margin-top: 10px;">
180
  ЛКМ: Разместить / Начать область<br>
181
  Отпустить ЛКМ: Закончить область<br>
@@ -185,8 +201,30 @@ EDITOR_TEMPLATE = '''
185
  </p>
186
  <button id="clear-level" class="danger-button">Очистить уровень</button>
187
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  </div>
 
 
 
 
 
 
189
  </div>
 
190
  <div id="blocker"><div id="instructions"><p style="font-size:36px">Нажмите, чтобы играть</p><p>Движение: WASD / Джойстик</p><p>Нажмите ESC для выхода</p></div></div>
191
  <div id="loading-spinner"></div>
192
  <div id="joystick-container"><div id="joystick-handle"></div></div>
@@ -211,7 +249,7 @@ EDITOR_TEMPLATE = '''
211
  let currentTool = { category: 'floors', type: 'grass' };
212
  let currentRotation = 0;
213
  const gridSize = 1;
214
- const levelData = { floors: {}, walls: {}, objects: {} };
215
  let drawMode = 'single'; // 'single' or 'area'
216
  let isDrawingArea = false;
217
  let areaStartPoint = new THREE.Vector3();
@@ -240,6 +278,9 @@ EDITOR_TEMPLATE = '''
240
 
241
  const instancedMeshes = {};
242
  const loadedTextures = {};
 
 
 
243
 
244
  function init() {
245
  showSpinner(true);
@@ -306,7 +347,7 @@ EDITOR_TEMPLATE = '''
306
  gridHelper = new THREE.GridHelper(100, 100, 0x556677, 0x556677);
307
  scene.add(gridHelper);
308
 
309
- const planeGeo = new THREE.PlaneGeometry(100, 100);
310
  planeGeo.rotateX(-Math.PI / 2);
311
  placementPlane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ visible: false }));
312
  scene.add(placementPlane);
@@ -325,6 +366,12 @@ EDITOR_TEMPLATE = '''
325
  );
326
  selectionBox.visible = false;
327
  scene.add(selectionBox);
 
 
 
 
 
 
328
 
329
  window.addEventListener('resize', onWindowResize);
330
  renderer.domElement.addEventListener('pointermove', onPointerMove);
@@ -399,12 +446,111 @@ EDITOR_TEMPLATE = '''
399
 
400
  document.getElementById('draw-mode-single').addEventListener('click', () => setDrawMode('single'));
401
  document.getElementById('draw-mode-area').addEventListener('click', () => setDrawMode('area'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  }
403
 
404
  function setDrawMode(mode) {
405
  drawMode = mode;
406
  document.getElementById('draw-mode-single').classList.toggle('active', mode === 'single');
407
  document.getElementById('draw-mode-area').classList.toggle('active', mode === 'area');
 
408
  }
409
 
410
  function initJoystick() {
@@ -438,20 +584,48 @@ EDITOR_TEMPLATE = '''
438
 
439
  function selectTool(category, type) {
440
  currentTool = { category, type };
441
- document.querySelectorAll('#tool-selector .tool-item').forEach(el => el.classList.remove('active'));
442
  const activeEl = document.querySelector(`.tool-item[data-category="${category}"][data-type="${type}"]`);
443
  if (activeEl) activeEl.classList.add('active');
444
 
445
- if (previewMesh) scene.remove(previewMesh);
446
- const asset = ASSETS[category][type];
447
- let geometry;
448
- if(asset.geometry === 'cylinder') {
449
- geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  } else {
451
- geometry = new THREE.BoxGeometry(...asset.size);
 
 
 
 
 
 
 
 
452
  }
453
- const material = new THREE.MeshStandardMaterial({ map: loadedTextures[category][type], transparent: true, opacity: 0.6 });
454
- previewMesh = new THREE.Mesh(geometry, material);
455
  scene.add(previewMesh);
456
  }
457
 
@@ -472,7 +646,7 @@ EDITOR_TEMPLATE = '''
472
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
473
  raycaster.setFromCamera(mouse, camera);
474
  const intersects = raycaster.intersectObject(placementPlane);
475
- if (intersects.length > 0) {
476
  const point = intersects[0].point;
477
  const gridX = Math.round(point.x / gridSize);
478
  const gridZ = Math.round(point.z / gridSize);
@@ -491,11 +665,15 @@ EDITOR_TEMPLATE = '''
491
  previewMesh.visible = false;
492
  } else {
493
  previewMesh.position.set(gridX * gridSize, 0, gridZ * gridSize);
494
- const asset = ASSETS[currentTool.category][currentTool.type];
495
- previewMesh.position.y = asset.size[1] / 2;
 
 
 
 
496
  previewMesh.visible = true;
497
  }
498
- } else {
499
  previewMesh.visible = false;
500
  }
501
  }
@@ -513,23 +691,26 @@ EDITOR_TEMPLATE = '''
513
  }
514
 
515
  function onPointerDown(event) {
516
- if (isPlayMode || !previewMesh.visible) return;
 
517
 
518
  if (event.button === 2) { rotatePreview(1); return; }
519
  if (event.button !== 0) return;
520
 
521
  const isRemoving = event.shiftKey;
522
- const pos = previewMesh.position;
523
 
524
- if (drawMode === 'area' && !isRemoving) {
 
 
 
 
 
 
 
525
  isDrawingArea = true;
526
  areaStartPoint.copy(pos);
527
  } else {
528
- if (isRemoving) {
529
- removeItemAt(pos);
530
- } else {
531
- addItem(pos, currentRotation);
532
- }
533
  updateLevelGeometry();
534
  }
535
  }
@@ -544,7 +725,6 @@ EDITOR_TEMPLATE = '''
544
  const point = intersects[0].point;
545
  const gridX = Math.round(point.x / gridSize);
546
  const gridZ = Math.round(point.z / gridSize);
547
- const endPoint = new THREE.Vector3(gridX * gridSize, 0, gridZ * gridSize);
548
 
549
  const startX = Math.round(areaStartPoint.x / gridSize);
550
  const startZ = Math.round(areaStartPoint.z / gridSize);
@@ -553,7 +733,7 @@ EDITOR_TEMPLATE = '''
553
 
554
  for (let x = Math.min(startX, endX); x <= Math.max(startX, endX); x++) {
555
  for (let z = Math.min(startZ, endZ); z <= Math.max(startZ, endZ); z++) {
556
- addItem(new THREE.Vector3(x * gridSize, 0, z * gridSize), 0); // Rotation 0 for terrain
557
  }
558
  }
559
  updateLevelGeometry();
@@ -568,14 +748,51 @@ EDITOR_TEMPLATE = '''
568
 
569
  function addItem(pos, rotation) {
570
  const { category, type } = currentTool;
571
- removeItemAt(pos);
572
- if (!levelData[category][type]) levelData[category][type] = [];
573
- levelData[category][type].push([pos.x, pos.z, rotation]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
  }
575
 
576
- function removeItemAt(pos) {
577
- const keyToRemove = `${pos.x},${pos.z}`;
 
 
578
  for (const category in levelData) {
 
579
  for (const type in levelData[category]) {
580
  levelData[category][type] = levelData[category][type].filter(item => {
581
  const itemKey = `${item[0]},${item[1]}`;
@@ -587,44 +804,93 @@ EDITOR_TEMPLATE = '''
587
 
588
  function updateLevelGeometry() {
589
  const dummy = new THREE.Object3D();
590
- collisionBBoxes = [];
591
 
592
  for (const category in ASSETS) {
593
  for (const type in ASSETS[category]) {
594
  const mesh = instancedMeshes[category][type];
595
  const dataArray = levelData[category]?.[type] || [];
596
  mesh.count = dataArray.length;
597
- const asset = ASSETS[category][type];
598
-
599
  for (let i = 0; i < dataArray.length; i++) {
600
  const [x, z, rot] = dataArray[i];
601
-
602
- dummy.position.set(x, 0, z);
603
  dummy.rotation.set(0, rot, 0);
604
- dummy.position.y = asset.size[1] / 2;
605
-
606
  dummy.updateMatrix();
607
  mesh.setMatrixAt(i, dummy.matrix);
608
-
609
- if (asset.solid) {
610
- const tempMesh = new THREE.Mesh(mesh.geometry);
611
- tempMesh.position.copy(dummy.position);
612
- tempMesh.rotation.copy(dummy.rotation);
613
- tempMesh.updateMatrixWorld();
614
- const box = new THREE.Box3().setFromObject(tempMesh, true);
615
- collisionBBoxes.push(box);
616
- }
617
  }
618
  mesh.instanceMatrix.needsUpdate = true;
619
  }
620
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  }
622
 
623
  function clearLevel() {
624
  if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
625
- levelData.floors = {};
626
- levelData.walls = {};
627
- levelData.objects = {};
628
  updateLevelGeometry();
629
  }
630
 
@@ -637,21 +903,23 @@ EDITOR_TEMPLATE = '''
637
  const joystickContainer = document.getElementById('joystick-container');
638
  uiContainer.style.display = 'flex';
639
  gridHelper.visible = true;
640
- previewMesh.visible = true;
641
  orbitControls.enabled = true;
642
  player.visible = false;
643
  blocker.style.display = 'none';
644
  joystickContainer.style.display = 'none';
 
645
  camera.position.set(50, 50, 50);
646
  camera.lookAt(0,0,0);
647
  orbitControls.target.set(0, 0, 0);
648
- onWindowResize(); // re-adjust camera
649
  } else { // Enter play mode
650
  blocker.style.display = 'block';
651
  }
652
  }
653
 
654
  function enterPlayMode() {
 
655
  isPlayMode = true;
656
  renderer.domElement.requestPointerLock?.();
657
  const uiContainer = document.getElementById('ui-container');
@@ -659,12 +927,12 @@ EDITOR_TEMPLATE = '''
659
  const joystickContainer = document.getElementById('joystick-container');
660
  uiContainer.style.display = 'none';
661
  gridHelper.visible = false;
662
- previewMesh.visible = false;
663
  orbitControls.enabled = false;
664
  player.visible = true;
665
  player.position.set(0, 0, 0);
666
  blocker.style.display = 'none';
667
- if ('ontouchstart' in window) { // Show joystick only on touch devices
668
  joystickContainer.style.display = 'block';
669
  }
670
  }
@@ -705,6 +973,7 @@ EDITOR_TEMPLATE = '''
705
 
706
  clearLevel();
707
  Object.assign(levelData, result.data);
 
708
  updateLevelGeometry();
709
  document.getElementById('project-name').value = projectName;
710
 
@@ -730,40 +999,74 @@ EDITOR_TEMPLATE = '''
730
  moveDirection.z += joystick.vector.y / maxRadius;
731
  }
732
 
733
- if (moveDirection.lengthSq() === 0) return;
734
- moveDirection.normalize().multiplyScalar(speed);
735
-
736
- const playerBox = new THREE.Box3().setFromObject(player);
737
-
738
- const intendedXPos = player.position.clone().add(new THREE.Vector3(moveDirection.x, 0, 0));
739
- const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0));
740
- let collisionX = false;
741
- for(const colBox of collisionBBoxes) {
742
- if (playerBoxX.intersectsBox(colBox)) {
743
- collisionX = true;
744
- break;
745
  }
746
- }
747
- if(!collisionX) player.position.x = intendedXPos.x;
748
 
749
- const intendedZPos = player.position.clone().add(new THREE.Vector3(0, 0, moveDirection.z));
750
- const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z));
751
- let collisionZ = false;
752
- for(const colBox of collisionBBoxes) {
753
- if (playerBoxZ.intersectsBox(colBox)) {
754
- collisionZ = true;
755
- break;
756
  }
 
757
  }
758
- if(!collisionZ) player.position.z = intendedZPos.z;
759
 
760
  const cameraOffset = new THREE.Vector3(30, 30, 30);
761
  camera.position.copy(player.position).add(cameraOffset);
762
  camera.lookAt(player.position);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
763
  }
764
 
765
  function showSpinner(show) {
766
  document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
 
767
  }
768
 
769
  function animate() {
 
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"], input[type="number"], 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 {
 
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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
118
  }
119
  .tool-item:hover { background: #3a3a3a; border-color: #666; }
120
  .tool-item.active { background: #0077cc; border-color: #00aaff; }
 
123
  #loading-spinner {
124
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
125
  border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
126
+ width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 10001;
127
  }
128
+ #blocker { position: fixed; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; top:0; left:0; z-index: 9999; }
129
  #instructions { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; font-size: 14px; cursor: pointer; color: white; }
130
  #burger-menu {
131
  position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
 
137
  display: none; z-index: 100; pointer-events: auto; user-select: none;
138
  }
139
  #joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
140
+
141
+ #building-modal {
142
+ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
143
+ background: rgba(20, 20, 20, 0.95);
144
+ padding: 20px; border-radius: 8px; z-index: 10000;
145
+ border: 1px solid #555; box-shadow: 0 0 20px rgba(0,0,0,0.5);
146
+ max-width: 90vw; max-height: 90vh; overflow-y: auto;
147
+ }
148
+ #building-preview-container { position: relative; cursor: crosshair; margin-bottom: 15px; }
149
+ #building-preview-img { max-width: 100%; max-height: 60vh; display: block; }
150
+ #building-entrance-canvas { position: absolute; top: 0; left: 0; pointer-events: none; }
151
+
152
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
153
  @media (max-width: 800px) {
154
  #ui-panel { transform: translateX(-100%); padding-top: 60px; }
 
188
  <button id="draw-mode-area" class="tool-item">Область</button>
189
  </div>
190
  <div id="tool-selector" class="tool-selector"></div>
191
+ <div id="custom-building-options" style="display:none; margin-top:10px;">
192
+ <label>Ширина:</label>
193
+ <input type="number" id="building-width-input" value="10" step="0.5">
194
+ </div>
195
  <p style="font-size: 0.8em; color: #888; margin-top: 10px;">
196
  ЛКМ: Разместить / Начать область<br>
197
  Отпустить ЛКМ: Закончить область<br>
 
201
  </p>
202
  <button id="clear-level" class="danger-button">Очистить уровень</button>
203
  </div>
204
+ <div class="ui-group">
205
+ <h3>Кастомные здания</h3>
206
+ <button id="add-building-btn">Добавить здание</button>
207
+ <input type="file" id="building-image-input" accept="image/png" style="display: none;">
208
+ <div id="custom-tools-selector" class="tool-selector" style="margin-top: 10px;"></div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <div id="building-modal" style="display: none;">
214
+ <h3>Новое здание</h3>
215
+ <p style="font-size: 0.9em; color: #ccc;">Кликните на изображение, чтобы указать точку входа.</p>
216
+ <div id="building-preview-container">
217
+ <img id="building-preview-img" alt="Building Preview">
218
+ <canvas id="building-entrance-canvas"></canvas>
219
  </div>
220
+ <label style="display: flex; align-items: center; gap: 10px;">
221
+ <input type="checkbox" id="building-solid-checkbox" checked>
222
+ <span>Плотное здание (с коллзией)</span>
223
+ </label>
224
+ <button id="save-new-building">Сохранить и выбрать</button>
225
+ <button id="cancel-new-building" class="danger-button" style="background-color: #555;">Отмена</button>
226
  </div>
227
+
228
  <div id="blocker"><div id="instructions"><p style="font-size:36px">Нажмите, чтобы играть</p><p>Движение: WASD / Джойстик</p><p>Нажмите ESC для выхода</p></div></div>
229
  <div id="loading-spinner"></div>
230
  <div id="joystick-container"><div id="joystick-handle"></div></div>
 
249
  let currentTool = { category: 'floors', type: 'grass' };
250
  let currentRotation = 0;
251
  const gridSize = 1;
252
+ let levelData = { floors: {}, walls: {}, objects: {}, buildingDefinitions: {}, buildingInstances: [] };
253
  let drawMode = 'single'; // 'single' or 'area'
254
  let isDrawingArea = false;
255
  let areaStartPoint = new THREE.Vector3();
 
278
 
279
  const instancedMeshes = {};
280
  const loadedTextures = {};
281
+ const customBuildingMeshes = [];
282
+ let entranceHelper;
283
+ let currentEntranceCoords = {x: 0.5, y: 0.95};
284
 
285
  function init() {
286
  showSpinner(true);
 
347
  gridHelper = new THREE.GridHelper(100, 100, 0x556677, 0x556677);
348
  scene.add(gridHelper);
349
 
350
+ const planeGeo = new THREE.PlaneGeometry(1000, 1000);
351
  planeGeo.rotateX(-Math.PI / 2);
352
  placementPlane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ visible: false }));
353
  scene.add(placementPlane);
 
366
  );
367
  selectionBox.visible = false;
368
  scene.add(selectionBox);
369
+
370
+ const spriteMaterial = new THREE.SpriteMaterial({ color: 0x00ff00, transparent: true, opacity: 0.7, depthTest: false });
371
+ entranceHelper = new THREE.Sprite(spriteMaterial);
372
+ entranceHelper.scale.set(1.5, 3, 1);
373
+ entranceHelper.visible = false;
374
+ scene.add(entranceHelper);
375
 
376
  window.addEventListener('resize', onWindowResize);
377
  renderer.domElement.addEventListener('pointermove', onPointerMove);
 
446
 
447
  document.getElementById('draw-mode-single').addEventListener('click', () => setDrawMode('single'));
448
  document.getElementById('draw-mode-area').addEventListener('click', () => setDrawMode('area'));
449
+
450
+ document.getElementById('add-building-btn').addEventListener('click', () => document.getElementById('building-image-input').click());
451
+ document.getElementById('building-image-input').addEventListener('change', handleImageUpload);
452
+ document.getElementById('save-new-building').addEventListener('click', saveNewBuildingDefinition);
453
+ document.getElementById('cancel-new-building').addEventListener('click', () => {
454
+ document.getElementById('building-modal').style.display = 'none';
455
+ document.getElementById('blocker').style.display = 'none';
456
+ });
457
+ document.getElementById('building-preview-container').addEventListener('click', setEntrancePoint);
458
+ document.getElementById('building-width-input').addEventListener('input', updatePreviewMesh);
459
+ }
460
+
461
+ function handleImageUpload(event) {
462
+ const file = event.target.files[0];
463
+ if (!file) return;
464
+
465
+ const reader = new FileReader();
466
+ reader.onload = (e) => {
467
+ const modal = document.getElementById('building-modal');
468
+ const img = document.getElementById('building-preview-img');
469
+ img.onload = () => {
470
+ const canvas = document.getElementById('building-entrance-canvas');
471
+ canvas.width = img.clientWidth;
472
+ canvas.height = img.clientHeight;
473
+ currentEntranceCoords = {x: 0.5, y: 0.95};
474
+ drawEntranceMarker();
475
+ };
476
+ img.src = e.target.result;
477
+ modal.style.display = 'block';
478
+ document.getElementById('blocker').style.display = 'block';
479
+ };
480
+ reader.readAsDataURL(file);
481
+ event.target.value = '';
482
+ }
483
+
484
+ function setEntrancePoint(event) {
485
+ const rect = event.target.getBoundingClientRect();
486
+ const x = event.clientX - rect.left;
487
+ const y = event.clientY - rect.top;
488
+ currentEntranceCoords = { x: x / rect.width, y: y / rect.height };
489
+ drawEntranceMarker();
490
+ }
491
+
492
+ function drawEntranceMarker() {
493
+ const canvas = document.getElementById('building-entrance-canvas');
494
+ const ctx = canvas.getContext('2d');
495
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
496
+ const x = currentEntranceCoords.x * canvas.width;
497
+ const y = currentEntranceCoords.y * canvas.height;
498
+ ctx.beginPath();
499
+ ctx.arc(x, y, 5, 0, 2 * Math.PI, false);
500
+ ctx.fillStyle = 'rgba(0, 255, 0, 0.8)';
501
+ ctx.fill();
502
+ ctx.lineWidth = 2;
503
+ ctx.strokeStyle = '#ffffff';
504
+ ctx.stroke();
505
+ }
506
+
507
+ function saveNewBuildingDefinition() {
508
+ const img = document.getElementById('building-preview-img');
509
+ const def = {
510
+ src: img.src,
511
+ isSolid: document.getElementById('building-solid-checkbox').checked,
512
+ entrance: currentEntranceCoords,
513
+ aspectRatio: img.naturalHeight / img.naturalWidth,
514
+ };
515
+ const defId = `building_${Date.now()}`;
516
+ levelData.buildingDefinitions[defId] = def;
517
+
518
+ document.getElementById('building-modal').style.display = 'none';
519
+ document.getElementById('blocker').style.display = 'none';
520
+
521
+ updateCustomToolsUI();
522
+ selectTool('custom', defId);
523
+ }
524
+
525
+ function updateCustomToolsUI() {
526
+ const selector = document.getElementById('custom-tools-selector');
527
+ selector.innerHTML = '';
528
+ for (const defId in levelData.buildingDefinitions) {
529
+ const def = levelData.buildingDefinitions[defId];
530
+ const item = document.createElement('div');
531
+ item.className = 'tool-item';
532
+ item.dataset.category = 'custom';
533
+ item.dataset.type = defId;
534
+ item.title = defId;
535
+
536
+ const img = document.createElement('img');
537
+ img.src = def.src;
538
+ img.style.width = '100%';
539
+ img.style.height = '40px';
540
+ img.style.objectFit = 'cover';
541
+ img.style.pointerEvents = 'none';
542
+
543
+ item.appendChild(img);
544
+ item.addEventListener('click', () => selectTool('custom', defId));
545
+ selector.appendChild(item);
546
+ }
547
  }
548
 
549
  function setDrawMode(mode) {
550
  drawMode = mode;
551
  document.getElementById('draw-mode-single').classList.toggle('active', mode === 'single');
552
  document.getElementById('draw-mode-area').classList.toggle('active', mode === 'area');
553
+ document.getElementById('custom-building-options').style.display = 'none';
554
  }
555
 
556
  function initJoystick() {
 
584
 
585
  function selectTool(category, type) {
586
  currentTool = { category, type };
587
+ document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
588
  const activeEl = document.querySelector(`.tool-item[data-category="${category}"][data-type="${type}"]`);
589
  if (activeEl) activeEl.classList.add('active');
590
 
591
+ document.getElementById('custom-building-options').style.display = category === 'custom' ? 'block' : 'none';
592
+ document.getElementById('draw-mode-area').style.display = category === 'custom' ? 'none' : 'flex';
593
+ if (category === 'custom') setDrawMode('single');
594
+
595
+ updatePreviewMesh();
596
+ }
597
+
598
+ function updatePreviewMesh() {
599
+ if (previewMesh) {
600
+ scene.remove(previewMesh);
601
+ previewMesh.geometry.dispose();
602
+ previewMesh.material.dispose();
603
+ }
604
+
605
+ let geometry, material;
606
+ if (currentTool.category === 'custom') {
607
+ const def = levelData.buildingDefinitions[currentTool.type];
608
+ if (!def) return;
609
+ const width = parseFloat(document.getElementById('building-width-input').value) || 10;
610
+ const height = width * def.aspectRatio;
611
+
612
+ geometry = new THREE.PlaneGeometry(width, height);
613
+ const texture = new THREE.TextureLoader().load(def.src);
614
+ material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.7, side: THREE.DoubleSide });
615
+ previewMesh = new THREE.Mesh(geometry, material);
616
+ previewMesh.userData.isBuilding = true;
617
+ previewMesh.userData.size = {x: width, y: height, z: 0.2};
618
  } else {
619
+ const asset = ASSETS[currentTool.category][currentTool.type];
620
+ if(asset.geometry === 'cylinder') {
621
+ geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
622
+ } else {
623
+ geometry = new THREE.BoxGeometry(...asset.size);
624
+ }
625
+ material = new THREE.MeshStandardMaterial({ map: loadedTextures[currentTool.category][currentTool.type], transparent: true, opacity: 0.6 });
626
+ previewMesh = new THREE.Mesh(geometry, material);
627
+ previewMesh.userData.isBuilding = false;
628
  }
 
 
629
  scene.add(previewMesh);
630
  }
631
 
 
646
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
647
  raycaster.setFromCamera(mouse, camera);
648
  const intersects = raycaster.intersectObject(placementPlane);
649
+ if (intersects.length > 0 && previewMesh) {
650
  const point = intersects[0].point;
651
  const gridX = Math.round(point.x / gridSize);
652
  const gridZ = Math.round(point.z / gridSize);
 
665
  previewMesh.visible = false;
666
  } else {
667
  previewMesh.position.set(gridX * gridSize, 0, gridZ * gridSize);
668
+ if (previewMesh.userData.isBuilding) {
669
+ previewMesh.position.y = previewMesh.userData.size.y / 2;
670
+ } else {
671
+ const asset = ASSETS[currentTool.category][currentTool.type];
672
+ previewMesh.position.y = asset.size[1] / 2;
673
+ }
674
  previewMesh.visible = true;
675
  }
676
+ } else if (previewMesh) {
677
  previewMesh.visible = false;
678
  }
679
  }
 
691
  }
692
 
693
  function onPointerDown(event) {
694
+ if (isPlayMode || (previewMesh && !previewMesh.visible)) return;
695
+ if (event.target !== renderer.domElement) return;
696
 
697
  if (event.button === 2) { rotatePreview(1); return; }
698
  if (event.button !== 0) return;
699
 
700
  const isRemoving = event.shiftKey;
 
701
 
702
+ if (isRemoving) {
703
+ removeItemAtPointer();
704
+ return;
705
+ }
706
+
707
+ const pos = previewMesh.position.clone();
708
+
709
+ if (drawMode === 'area' && currentTool.category !== 'custom') {
710
  isDrawingArea = true;
711
  areaStartPoint.copy(pos);
712
  } else {
713
+ addItem(pos, currentRotation);
 
 
 
 
714
  updateLevelGeometry();
715
  }
716
  }
 
725
  const point = intersects[0].point;
726
  const gridX = Math.round(point.x / gridSize);
727
  const gridZ = Math.round(point.z / gridSize);
 
728
 
729
  const startX = Math.round(areaStartPoint.x / gridSize);
730
  const startZ = Math.round(areaStartPoint.z / gridSize);
 
733
 
734
  for (let x = Math.min(startX, endX); x <= Math.max(startX, endX); x++) {
735
  for (let z = Math.min(startZ, endZ); z <= Math.max(startZ, endZ); z++) {
736
+ addItem(new THREE.Vector3(x * gridSize, 0, z * gridSize), 0);
737
  }
738
  }
739
  updateLevelGeometry();
 
748
 
749
  function addItem(pos, rotation) {
750
  const { category, type } = currentTool;
751
+ if (category === 'custom') {
752
+ const def = levelData.buildingDefinitions[type];
753
+ if (!def) return;
754
+ const width = parseFloat(document.getElementById('building-width-input').value) || 10;
755
+ const height = width * def.aspectRatio;
756
+ levelData.buildingInstances.push({
757
+ defId: type,
758
+ pos: {x: pos.x, y: height/2, z: pos.z},
759
+ scale: {x: width, y: height, z: 0.2},
760
+ rotation: rotation
761
+ });
762
+ } else {
763
+ removeItemAtGrid(pos);
764
+ if (!levelData[category][type]) levelData[category][type] = [];
765
+ levelData[category][type].push([pos.x, pos.z, rotation]);
766
+ }
767
+ }
768
+
769
+ function removeItemAtPointer() {
770
+ raycaster.setFromCamera(mouse, camera);
771
+ const intersectsBuildings = raycaster.intersectObjects(customBuildingMeshes);
772
+ if (intersectsBuildings.length > 0) {
773
+ const intersectedMesh = intersectsBuildings[0].object;
774
+ const instanceIndex = customBuildingMeshes.indexOf(intersectedMesh);
775
+ if (instanceIndex > -1) {
776
+ levelData.buildingInstances.splice(instanceIndex, 1);
777
+ updateLevelGeometry();
778
+ return;
779
+ }
780
+ }
781
+
782
+ const intersectsPlane = raycaster.intersectObject(placementPlane);
783
+ if (intersectsPlane.length > 0) {
784
+ const pos = intersectsPlane[0].point;
785
+ removeItemAtGrid(pos);
786
+ updateLevelGeometry();
787
+ }
788
  }
789
 
790
+ function removeItemAtGrid(pos) {
791
+ const gridX = Math.round(pos.x / gridSize);
792
+ const gridZ = Math.round(pos.z / gridSize);
793
+ const keyToRemove = `${gridX * gridSize},${gridZ * gridSize}`;
794
  for (const category in levelData) {
795
+ if (category === 'buildingDefinitions' || category === 'buildingInstances') continue;
796
  for (const type in levelData[category]) {
797
  levelData[category][type] = levelData[category][type].filter(item => {
798
  const itemKey = `${item[0]},${item[1]}`;
 
804
 
805
  function updateLevelGeometry() {
806
  const dummy = new THREE.Object3D();
 
807
 
808
  for (const category in ASSETS) {
809
  for (const type in ASSETS[category]) {
810
  const mesh = instancedMeshes[category][type];
811
  const dataArray = levelData[category]?.[type] || [];
812
  mesh.count = dataArray.length;
813
+
 
814
  for (let i = 0; i < dataArray.length; i++) {
815
  const [x, z, rot] = dataArray[i];
816
+ const asset = ASSETS[category][type];
817
+ dummy.position.set(x, asset.size[1] / 2, z);
818
  dummy.rotation.set(0, rot, 0);
 
 
819
  dummy.updateMatrix();
820
  mesh.setMatrixAt(i, dummy.matrix);
 
 
 
 
 
 
 
 
 
821
  }
822
  mesh.instanceMatrix.needsUpdate = true;
823
  }
824
  }
825
+ renderCustomBuildings();
826
+ rebuildCollisionData();
827
+ }
828
+
829
+ function renderCustomBuildings() {
830
+ while(customBuildingMeshes.length){
831
+ const mesh = customBuildingMeshes.pop();
832
+ scene.remove(mesh);
833
+ mesh.geometry.dispose();
834
+ mesh.material.map?.dispose();
835
+ mesh.material.dispose();
836
+ }
837
+
838
+ const textureLoader = new THREE.TextureLoader();
839
+ for(const instance of levelData.buildingInstances) {
840
+ const def = levelData.buildingDefinitions[instance.defId];
841
+ if (!def) continue;
842
+
843
+ const geometry = new THREE.PlaneGeometry(instance.scale.x, instance.scale.y);
844
+ const texture = textureLoader.load(def.src);
845
+ const material = new THREE.MeshStandardMaterial({ map: texture, transparent: true, side: THREE.DoubleSide });
846
+ const mesh = new THREE.Mesh(geometry, material);
847
+
848
+ mesh.position.set(instance.pos.x, instance.pos.y, instance.pos.z);
849
+ mesh.rotation.y = instance.rotation;
850
+ mesh.castShadow = true;
851
+ mesh.receiveShadow = true;
852
+
853
+ scene.add(mesh);
854
+ customBuildingMeshes.push(mesh);
855
+ }
856
+ }
857
+
858
+ function rebuildCollisionData() {
859
+ collisionBBoxes = [];
860
+ for (const category in ASSETS) {
861
+ for (const type in ASSETS[category]) {
862
+ const asset = ASSETS[category][type];
863
+ if (!asset.solid) continue;
864
+ const dataArray = levelData[category]?.[type] || [];
865
+ for(const item of dataArray) {
866
+ const [x, z, rot] = item;
867
+ const box = new THREE.Box3();
868
+ const size = new THREE.Vector3(asset.size[0], asset.size[1], asset.size[2]);
869
+ const center = new THREE.Vector3(x, asset.size[1]/2, z);
870
+ box.setFromCenterAndSize(center, size);
871
+ collisionBBoxes.push(box);
872
+ }
873
+ }
874
+ }
875
+
876
+ for (let i = 0; i < levelData.buildingInstances.length; i++) {
877
+ const instance = levelData.buildingInstances[i];
878
+ const def = levelData.buildingDefinitions[instance.defId];
879
+ if (!def || !def.isSolid) continue;
880
+
881
+ const mesh = customBuildingMeshes[i];
882
+ if(mesh) {
883
+ mesh.updateMatrixWorld();
884
+ const box = new THREE.Box3().setFromObject(mesh);
885
+ collisionBBoxes.push(box);
886
+ }
887
+ }
888
  }
889
 
890
  function clearLevel() {
891
  if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
892
+ levelData = { floors: {}, walls: {}, objects: {}, buildingDefinitions: {}, buildingInstances: [] };
893
+ updateCustomToolsUI();
 
894
  updateLevelGeometry();
895
  }
896
 
 
903
  const joystickContainer = document.getElementById('joystick-container');
904
  uiContainer.style.display = 'flex';
905
  gridHelper.visible = true;
906
+ if(previewMesh) previewMesh.visible = true;
907
  orbitControls.enabled = true;
908
  player.visible = false;
909
  blocker.style.display = 'none';
910
  joystickContainer.style.display = 'none';
911
+ entranceHelper.visible = false;
912
  camera.position.set(50, 50, 50);
913
  camera.lookAt(0,0,0);
914
  orbitControls.target.set(0, 0, 0);
915
+ onWindowResize();
916
  } else { // Enter play mode
917
  blocker.style.display = 'block';
918
  }
919
  }
920
 
921
  function enterPlayMode() {
922
+ rebuildCollisionData();
923
  isPlayMode = true;
924
  renderer.domElement.requestPointerLock?.();
925
  const uiContainer = document.getElementById('ui-container');
 
927
  const joystickContainer = document.getElementById('joystick-container');
928
  uiContainer.style.display = 'none';
929
  gridHelper.visible = false;
930
+ if(previewMesh) previewMesh.visible = false;
931
  orbitControls.enabled = false;
932
  player.visible = true;
933
  player.position.set(0, 0, 0);
934
  blocker.style.display = 'none';
935
+ if ('ontouchstart' in window) {
936
  joystickContainer.style.display = 'block';
937
  }
938
  }
 
973
 
974
  clearLevel();
975
  Object.assign(levelData, result.data);
976
+ updateCustomToolsUI();
977
  updateLevelGeometry();
978
  document.getElementById('project-name').value = projectName;
979
 
 
999
  moveDirection.z += joystick.vector.y / maxRadius;
1000
  }
1001
 
1002
+ if (moveDirection.lengthSq() > 0) {
1003
+ moveDirection.normalize().multiplyScalar(speed);
1004
+
1005
+ const playerBox = new THREE.Box3().setFromObject(player);
1006
+
1007
+ const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0));
1008
+ let collisionX = false;
1009
+ for(const colBox of collisionBBoxes) {
1010
+ if (playerBoxX.intersectsBox(colBox)) {
1011
+ collisionX = true;
1012
+ break;
1013
+ }
1014
  }
1015
+ if(!collisionX) player.position.x += moveDirection.x;
 
1016
 
1017
+ const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z));
1018
+ let collisionZ = false;
1019
+ for(const colBox of collisionBBoxes) {
1020
+ if (playerBoxZ.intersectsBox(colBox)) {
1021
+ collisionZ = true;
1022
+ break;
1023
+ }
1024
  }
1025
+ if(!collisionZ) player.position.z += moveDirection.z;
1026
  }
 
1027
 
1028
  const cameraOffset = new THREE.Vector3(30, 30, 30);
1029
  camera.position.copy(player.position).add(cameraOffset);
1030
  camera.lookAt(player.position);
1031
+
1032
+ updateEntranceHighlight();
1033
+ }
1034
+
1035
+ function updateEntranceHighlight() {
1036
+ let closestEntranceDist = Infinity;
1037
+ let entranceToShow = null;
1038
+
1039
+ for(let i=0; i < levelData.buildingInstances.length; i++) {
1040
+ const instance = levelData.buildingInstances[i];
1041
+ const def = levelData.buildingDefinitions[instance.defId];
1042
+ if (!def || !def.entrance) continue;
1043
+
1044
+ const dx = (def.entrance.x - 0.5) * instance.scale.x;
1045
+ const dy = (def.entrance.y - 0.5) * instance.scale.y;
1046
+
1047
+ let entrancePos = new THREE.Vector3(dx, dy, 0);
1048
+ const rotationMatrix = new THREE.Matrix4().makeRotationY(instance.rotation);
1049
+ entrancePos.applyMatrix4(rotationMatrix);
1050
+ entrancePos.add(new THREE.Vector3(instance.pos.x, instance.pos.y, instance.pos.z));
1051
+
1052
+ const dist = player.position.distanceTo(entrancePos);
1053
+ if (dist < 5 && dist < closestEntranceDist) {
1054
+ closestEntranceDist = dist;
1055
+ entranceToShow = entrancePos;
1056
+ }
1057
+ }
1058
+
1059
+ if (entranceToShow) {
1060
+ entranceHelper.position.copy(entranceToShow);
1061
+ entranceHelper.visible = true;
1062
+ } else {
1063
+ entranceHelper.visible = false;
1064
+ }
1065
  }
1066
 
1067
  function showSpinner(show) {
1068
  document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
1069
+ document.getElementById('blocker').style.display = show ? 'block' : 'none';
1070
  }
1071
 
1072
  function animate() {