Kgshop commited on
Commit
e109dec
·
verified ·
1 Parent(s): 2a81240

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +280 -364
app.py CHANGED
@@ -1,4 +1,4 @@
1
- import os
2
  import json
3
  import time
4
  from datetime import datetime
@@ -127,8 +127,10 @@ EDITOR_TEMPLATE = '''
127
  button.play-button:hover { background: #33cc33; }
128
  .slider-container { margin-top: 10px; }
129
  input[type="range"] { width: 100%; }
130
- .radio-group label { display: inline-block; margin-right: 10px; cursor: pointer; }
 
131
  .radio-group input { margin-right: 5px; }
 
132
 
133
  #loading-spinner {
134
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
@@ -202,10 +204,16 @@ EDITOR_TEMPLATE = '''
202
  <div class="radio-group">
203
  <label><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
204
  <label><input type="radio" name="brush-mode" value="lower"> Опустить</label>
 
 
205
  <label><input type="radio" name="brush-mode" value="roughen"> Шум</label>
206
  <label><input type="radio" name="brush-mode" value="paint"> Текстура</label>
207
  <label><input type="radio" name="brush-mode" value="place"> Объект</label>
208
  </div>
 
 
 
 
209
  <div class="slider-container">
210
  <label for="brush-size">Размер: <span id="brush-size-value">10</span></label>
211
  <input type="range" id="brush-size" min="1" max="50" value="10">
@@ -218,27 +226,25 @@ EDITOR_TEMPLATE = '''
218
  <div class="ui-group">
219
  <h3>Текстуры ландшафта</h3>
220
  <div class="radio-group" id="texture-selector">
221
- <label><input type="radio" name="texture-type" value="grass" checked> Трава</label>
222
- <label><input type="radio" name="texture-type" value="rock"> Скалы</label>
223
- <label><input type="radio" name="texture-type" value="dirt"> Грунт</label>
224
- <label><input type="radio" name="texture-type" value="snow"> Снег</label>
225
- <label><input type="radio" name="texture-type" value="sand"> Песок</label>
226
  </div>
227
  </div>
228
  <div class="ui-group">
229
  <h3>Управление текстурами</h3>
230
- <label for="texture-to-replace">Заменить текстуру:</label>
231
  <select id="texture-to-replace">
232
- <option value="grass">Трава</option>
233
- <option value="rock">Скалы</option>
234
- <option value="dirt">Грунт</option>
235
- <option value="snow">Снег</option>
236
- <option value="sand">Песок</option>
237
  </select>
238
- <label for="custom-texture-file">Выберите ��айл:</label>
239
  <input type="file" id="custom-texture-file" accept="image/*">
240
  <button id="update-texture-btn">Обновить текстуру</button>
241
  </div>
 
 
 
 
 
 
 
 
242
  <div class="ui-group">
243
  <h3>Объекты</h3>
244
  <div class="radio-group" id="object-selector">
@@ -288,6 +294,7 @@ EDITOR_TEMPLATE = '''
288
  let raycaster = new THREE.Raycaster();
289
  let mouse = new THREE.Vector2();
290
  let isEditing = false;
 
291
  const noise2D = createNoise2D();
292
 
293
  let grassInstances, grassMaterial;
@@ -305,6 +312,10 @@ EDITOR_TEMPLATE = '''
305
  const clock = new THREE.Clock();
306
 
307
  const textureLoader = new THREE.TextureLoader();
 
 
 
 
308
  const loadTexture = (url) => {
309
  const tex = textureLoader.load(url);
310
  tex.wrapS = THREE.RepeatWrapping;
@@ -313,89 +324,60 @@ EDITOR_TEMPLATE = '''
313
  return tex;
314
  };
315
 
316
- const textures = {
317
- grass: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/grasslight-big.jpg'),
318
- rock: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/rock.jpg'),
319
- dirt: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg'),
320
- snow: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/snow.jpg'),
321
- sand: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg'),
322
- grassNormal: loadTexture('https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/terrain/grasslight-big-nm.jpg'),
323
- rockNormal: loadTexture('https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/terrain/rock-nm.jpg')
324
  };
 
 
 
 
 
 
 
 
 
 
325
 
326
  const terrainMaterial = new THREE.ShaderMaterial({
327
- uniforms: {
328
- grassTexture: { value: textures.grass },
329
- rockTexture: { value: textures.rock },
330
- dirtTexture: { value: textures.dirt },
331
- snowTexture: { value: textures.snow },
332
- sandTexture: { value: textures.sand },
333
- grassNormalMap: { value: textures.grassNormal },
334
- rockNormalMap: { value: textures.rockNormal },
335
- lightDirection: { value: new THREE.Vector3(0.5, 0.5, 0.5).normalize() }
336
- },
337
  vertexShader: `
338
  varying vec2 vUv;
339
  varying vec3 vNormal;
340
- varying vec3 vViewPosition;
341
  attribute vec4 color;
 
342
  varying vec4 vColor;
343
- attribute vec4 tangent;
344
- varying vec3 vTangent;
345
- varying vec3 vBitangent;
346
-
347
  void main() {
348
  vUv = uv;
349
  vColor = color;
350
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
351
- vViewPosition = -mvPosition.xyz;
352
  vNormal = normalize(normalMatrix * normal);
353
- vTangent = normalize(normalMatrix * tangent.xyz);
354
- vBitangent = normalize(cross(vNormal, vTangent) * tangent.w);
355
- gl_Position = projectionMatrix * mvPosition;
356
  }
357
  `,
358
  fragmentShader: `
359
- uniform sampler2D grassTexture;
360
- uniform sampler2D rockTexture;
361
- uniform sampler2D dirtTexture;
362
- uniform sampler2D snowTexture;
363
- uniform sampler2D sandTexture;
364
- uniform sampler2D grassNormalMap;
365
- uniform sampler2D rockNormalMap;
366
  uniform vec3 lightDirection;
367
-
368
  varying vec2 vUv;
369
  varying vec4 vColor;
 
370
  varying vec3 vNormal;
371
- varying vec3 vViewPosition;
372
- varying vec3 vTangent;
373
- varying vec3 vBitangent;
374
-
375
  void main() {
376
  vec2 uv_scaled = vUv * 30.0;
 
 
 
 
 
 
 
 
377
 
378
- vec4 grass = texture2D(grassTexture, uv_scaled);
379
- vec4 rock = texture2D(rockTexture, uv_scaled);
380
- vec4 dirt = texture2D(dirtTexture, uv_scaled);
381
- vec4 snow = texture2D(snowTexture, uv_scaled);
382
- vec4 sand = texture2D(sandTexture, uv_scaled);
383
-
384
- vec3 finalColor = grass.rgb;
385
- finalColor = mix(finalColor, rock.rgb, vColor.r);
386
- finalColor = mix(finalColor, dirt.rgb, vColor.g);
387
- finalColor = mix(finalColor, snow.rgb, vColor.b);
388
- finalColor = mix(finalColor, sand.rgb, vColor.a);
389
-
390
- mat3 tbn = mat3(vTangent, vBitangent, vNormal);
391
-
392
- vec3 grassNormal = texture2D(grassNormalMap, uv_scaled).xyz * 2.0 - 1.0;
393
- vec3 rockNormal = texture2D(rockNormalMap, uv_scaled).xyz * 2.0 - 1.0;
394
-
395
- vec3 blendedNormal = normalize(mix(grassNormal, rockNormal, vColor.r));
396
- vec3 normal = normalize(tbn * blendedNormal);
397
-
398
- float lighting = dot(normal, lightDirection) * 0.5 + 0.5;
399
  gl_FragColor = vec4(finalColor * lighting, 1.0);
400
  }
401
  `
@@ -403,7 +385,6 @@ EDITOR_TEMPLATE = '''
403
 
404
  function init() {
405
  scene = new THREE.Scene();
406
-
407
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
408
  camera.position.set(50, 50, 50);
409
 
@@ -411,7 +392,6 @@ EDITOR_TEMPLATE = '''
411
  renderer.setSize(window.innerWidth, window.innerHeight);
412
  renderer.setPixelRatio(window.devicePixelRatio);
413
  renderer.shadowMap.enabled = true;
414
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
415
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
416
  renderer.outputColorSpace = THREE.SRGBColorSpace;
417
  document.body.appendChild(renderer.domElement);
@@ -428,11 +408,6 @@ EDITOR_TEMPLATE = '''
428
  dirLight.castShadow = true;
429
  dirLight.shadow.mapSize.width = 2048;
430
  dirLight.shadow.mapSize.height = 2048;
431
- dirLight.shadow.camera.top = 100;
432
- dirLight.shadow.camera.bottom = -100;
433
- dirLight.shadow.camera.left = -100;
434
- dirLight.shadow.camera.right = 100;
435
- dirLight.shadow.bias = -0.001;
436
  scene.add(dirLight);
437
  terrainMaterial.uniforms.lightDirection.value = dirLight.position.clone().normalize();
438
 
@@ -440,23 +415,16 @@ EDITOR_TEMPLATE = '''
440
  sky.scale.setScalar(450000);
441
  scene.add(sky);
442
  sun = new THREE.Vector3();
443
- const effectController = {
444
- turbidity: 10,
445
- rayleigh: 3,
446
- mieCoefficient: 0.005,
447
- mieDirectionalG: 0.7,
448
- elevation: 4,
449
- azimuth: 180,
450
- };
451
  const uniforms = sky.material.uniforms;
452
- uniforms[ 'turbidity' ].value = effectController.turbidity;
453
- uniforms[ 'rayleigh' ].value = effectController.rayleigh;
454
- uniforms[ 'mieCoefficient' ].value = effectController.mieCoefficient;
455
- uniforms[ 'mieDirectionalG' ].value = effectController.mieDirectionalG;
456
- const phi = THREE.MathUtils.degToRad( 90 - effectController.elevation );
457
- const theta = THREE.MathUtils.degToRad( effectController.azimuth );
458
- sun.setFromSphericalCoords( 1, phi, theta );
459
- uniforms[ 'sunPosition' ].value.copy( sun );
460
  dirLight.position.copy(sun).multiplyScalar(100);
461
 
462
  const brushGeometry = new THREE.CylinderGeometry(1, 1, 100, 32, 1, true);
@@ -468,78 +436,45 @@ EDITOR_TEMPLATE = '''
468
  initFoliage();
469
  initPostprocessing();
470
  initPlayer();
471
-
472
- window.addEventListener('resize', onWindowResize);
473
- renderer.domElement.addEventListener('pointermove', onPointerMove);
474
- renderer.domElement.addEventListener('pointerdown', onPointerDown);
475
- renderer.domElement.addEventListener('pointerup', onPointerUp);
476
- renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
477
-
478
  setupUIListeners();
479
  animate();
480
  }
481
 
482
  function initPostprocessing() {
483
  composer = new EffectComposer(renderer);
484
- const renderPass = new RenderPass(scene, camera);
485
- composer.addPass(renderPass);
486
-
487
  const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
488
  ssaoPass.kernelRadius = 16;
489
- ssaoPass.minDistance = 0.005;
490
- ssaoPass.maxDistance = 0.1;
491
  composer.addPass(ssaoPass);
492
-
493
- const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.5, 0.4, 0.85);
494
- composer.addPass(bloomPass);
495
  }
496
 
497
  function initFoliage() {
498
- if (grassInstances) {
499
- scene.remove(grassInstances);
500
- grassInstances.geometry.dispose();
501
- grassMaterial.dispose();
502
- }
503
  const grassTexture = textureLoader.load('https://threejs.org/examples/textures/sprites/grass.png');
504
  grassMaterial = new THREE.ShaderMaterial({
505
- uniforms: {
506
- time: { value: 0 },
507
- map: { value: grassTexture }
508
- },
509
  vertexShader: `
510
- uniform float time;
511
- varying vec2 vUv;
512
-
513
  void main() {
514
- vUv = uv;
515
- vec3 pos = position;
516
-
517
- mat4 instanceMatrix_ = instanceMatrix;
518
- vec3 instancePosition = vec3(instanceMatrix_[3][0], instanceMatrix_[3][1], instanceMatrix_[3][2]);
519
-
520
- float windStrength = 0.2;
521
- float windSpeed = 2.0;
522
  float noise = (sin(instancePosition.x * 0.5 + time * windSpeed) + cos(instancePosition.z * 0.5 + time * windSpeed)) * 0.5 + 0.5;
523
-
524
- if (pos.y > 0.1) { // Only affect top vertices
525
- pos.x += noise * windStrength;
526
- }
527
-
528
  gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(pos, 1.0);
529
  }
530
  `,
531
  fragmentShader: `
532
- uniform sampler2D map;
533
- varying vec2 vUv;
534
-
535
  void main() {
536
  vec4 texColor = texture2D(map, vUv);
537
  if (texColor.a < 0.5) discard;
538
  gl_FragColor = texColor;
539
  }
540
  `,
541
- side: THREE.DoubleSide,
542
- transparent: true
543
  });
544
 
545
  const grassBlade = new THREE.PlaneGeometry(0.8, 1.8);
@@ -553,108 +488,123 @@ EDITOR_TEMPLATE = '''
553
  function initPlayer() {
554
  player = new THREE.Group();
555
  scene.add(player);
556
-
557
  pointerLockControls = new PointerLockControls(camera, document.body);
558
  const blocker = document.getElementById('blocker');
559
  const instructions = document.getElementById('instructions');
560
  instructions.addEventListener('click', () => pointerLockControls.lock());
561
  pointerLockControls.addEventListener('lock', () => { instructions.style.display = 'none'; blocker.style.display = 'none'; });
562
  pointerLockControls.addEventListener('unlock', () => { blocker.style.display = 'block'; instructions.style.display = ''; });
563
-
564
  document.addEventListener('keydown', (event) => { keyStates[event.code] = true; });
565
  document.addEventListener('keyup', (event) => { keyStates[event.code] = false; });
566
- document.addEventListener('keydown', (event) => {
567
- if (isPlayMode && event.code === 'Escape') {
568
- togglePlayMode();
569
- }
570
- });
571
  }
572
 
573
  function setupUIListeners() {
574
- document.getElementById('create-terrain').addEventListener('click', () => {
575
- const width = parseInt(document.getElementById('terrain-width').value);
576
- const height = parseInt(document.getElementById('terrain-height').value);
577
- createTerrain(width, height);
578
- });
579
  document.getElementById('save-project').addEventListener('click', saveProject);
580
  document.getElementById('load-project').addEventListener('click', loadProject);
581
- document.getElementById('brush-size').addEventListener('input', (e) => {
582
- document.getElementById('brush-size-value').textContent = e.target.value;
583
- updateBrushHelper();
584
- });
585
- document.getElementById('brush-strength').addEventListener('input', (e) => {
586
- document.getElementById('brush-strength-value').textContent = e.target.value;
587
- });
588
- document.getElementById('project-list').addEventListener('change', (e) => {
589
- document.getElementById('project-name').value = e.target.value;
590
- });
591
- document.getElementById('burger-menu').addEventListener('click', () => {
592
- document.getElementById('ui-panel').classList.toggle('open');
593
- });
594
- document.getElementById('clear-objects').addEventListener('click', () => {
595
- if (confirm('Вы уверены, что хотите удалить все объекты?')) {
596
- grassInstances.count = 0;
597
- grassInstances.instanceMatrix.needsUpdate = true;
598
- }
599
- });
600
  document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
601
  document.getElementById('update-texture-btn').addEventListener('click', updateCustomTexture);
 
 
602
  }
603
 
604
- function updateCustomTexture() {
605
- const fileInput = document.getElementById('custom-texture-file');
606
- const textureSlot = document.getElementById('texture-to-replace').value;
607
-
608
- if (fileInput.files.length === 0) {
609
- alert('Пожалуйста, выберите файл изображения.');
 
 
 
 
 
610
  return;
611
  }
 
 
612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  const file = fileInput.files[0];
614
- const url = URL.createObjectURL(file);
615
 
616
- const newTexture = loadTexture(url);
 
 
 
 
 
 
 
 
 
 
 
 
 
617
 
618
- const uniformName = textureSlot + 'Texture';
 
619
 
620
- if (terrainMaterial.uniforms[uniformName]) {
621
- const oldTexture = terrainMaterial.uniforms[uniformName].value;
622
- if (oldTexture) {
623
- oldTexture.dispose();
624
- }
625
- terrainMaterial.uniforms[uniformName].value = newTexture;
626
- terrainMaterial.needsUpdate = true;
627
- alert(`Текстура для "${textureSlot}" успешно обновлена.`);
628
- }
629
  }
630
 
631
  function togglePlayMode() {
632
  isPlayMode = !isPlayMode;
633
  const toggleButton = document.getElementById('play-mode-toggle');
634
  const uiPanel = document.getElementById('ui-container');
635
-
636
  if (isPlayMode) {
637
  toggleButton.textContent = "Редактор";
638
- toggleButton.classList.remove('play-button');
639
  uiPanel.style.display = 'none';
640
  brushHelper.visible = false;
641
  orbitControls.enabled = false;
642
-
643
  const spawnRaycaster = new THREE.Raycaster(new THREE.Vector3(0, 100, 0), new THREE.Vector3(0, -1, 0));
644
  const intersects = spawnRaycaster.intersectObject(terrainMesh);
645
- if (intersects.length > 0) {
646
- player.position.copy(intersects[0].point);
647
- } else {
648
- player.position.set(0, 0, 0);
649
- }
650
  player.position.y += playerHeight;
651
  playerVelocity.set(0,0,0);
652
-
653
  pointerLockControls.lock();
654
-
655
  } else {
656
  toggleButton.textContent = "Играть";
657
- toggleButton.classList.add('play-button');
658
  uiPanel.style.display = 'block';
659
  orbitControls.enabled = true;
660
  pointerLockControls.unlock();
@@ -666,10 +616,7 @@ EDITOR_TEMPLATE = '''
666
  }
667
 
668
  function createTerrain(width, height, terrainData = null) {
669
- if (terrainMesh) {
670
- scene.remove(terrainMesh);
671
- terrainMesh.geometry.dispose();
672
- }
673
  initFoliage();
674
 
675
  terrainDimensions.width = width;
@@ -677,38 +624,29 @@ EDITOR_TEMPLATE = '''
677
  terrainDimensions.segmentsX = Math.max(1, Math.round(width));
678
  terrainDimensions.segmentsY = Math.max(1, Math.round(height));
679
 
680
- const geometry = new THREE.PlaneGeometry(
681
- terrainDimensions.width, terrainDimensions.height,
682
- terrainDimensions.segmentsX, terrainDimensions.segmentsY
683
- );
684
  geometry.rotateX(-Math.PI / 2);
685
 
686
- const colors = new Float32Array(geometry.attributes.position.count * 4);
687
- geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
688
 
689
  if (terrainData) {
690
- const positions = geometry.attributes.position;
691
- for (let i = 0; i < positions.count; i++) {
692
- positions.setY(i, terrainData.heights[i]);
693
- }
694
- if (terrainData.colors) {
695
- geometry.attributes.color.array.set(terrainData.colors);
696
- }
697
- if (terrainData.foliage && terrainData.foliage.grass) {
698
  const matrices = terrainData.foliage.grass;
699
  grassInstances.count = matrices.length / 16;
700
  for (let i = 0; i < grassInstances.count; i++) {
701
- const matrix = new THREE.Matrix4();
702
- matrix.fromArray(matrices, i * 16);
703
- grassInstances.setMatrixAt(i, matrix);
704
  }
705
  grassInstances.instanceMatrix.needsUpdate = true;
706
  }
707
  }
708
 
709
- geometry.computeTangents();
710
  geometry.computeVertexNormals();
711
-
712
  terrainMesh = new THREE.Mesh(geometry, terrainMaterial);
713
  terrainMesh.castShadow = true;
714
  terrainMesh.receiveShadow = true;
@@ -722,31 +660,21 @@ EDITOR_TEMPLATE = '''
722
  composer.setSize(window.innerWidth, window.innerHeight);
723
  }
724
 
725
- function getBrushMode() {
726
- return document.querySelector('input[name="brush-mode"]:checked').value;
727
- }
728
 
729
  function onPointerMove(event) {
730
  if (isPlayMode) return;
731
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
732
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
733
-
734
  raycaster.setFromCamera(mouse, camera);
735
  if (!terrainMesh) return;
736
-
737
  const intersects = raycaster.intersectObject(terrainMesh);
738
-
739
  if (intersects.length > 0) {
740
- const intersectionPoint = intersects[0].point;
741
- brushHelper.position.copy(intersectionPoint);
742
- brushHelper.position.y = 0;
743
  brushHelper.visible = true;
744
  updateBrushHelper();
745
  orbitControls.enabled = !isEditing;
746
-
747
- if (isEditing) {
748
- applyBrush(intersects[0]);
749
- }
750
  } else {
751
  brushHelper.visible = false;
752
  orbitControls.enabled = true;
@@ -759,22 +687,18 @@ EDITOR_TEMPLATE = '''
759
  }
760
 
761
  function onPointerDown(event) {
762
- if (isPlayMode) return;
763
- if (event.button === 0 && mouse.x > -0.9) {
764
- if (window.innerWidth < 800 && !document.getElementById('ui-panel').contains(event.target)) {
765
- document.getElementById('ui-panel').classList.remove('open');
766
- }
767
- isEditing = true;
768
- orbitControls.enabled = false;
769
  }
770
  }
771
 
772
  function onPointerUp(event) {
773
- if (isPlayMode) return;
774
- if (event.button === 0) {
775
- isEditing = false;
776
- orbitControls.enabled = true;
777
- }
778
  }
779
 
780
  function applyBrush(intersection) {
@@ -782,6 +706,8 @@ EDITOR_TEMPLATE = '''
782
  const brushMode = getBrushMode();
783
  switch(brushMode) {
784
  case 'raise': case 'lower': sculptTerrain(intersection.point, brushMode); break;
 
 
785
  case 'roughen': roughenTerrain(intersection.point); break;
786
  case 'paint': paintTexture(intersection.point); break;
787
  case 'place': placeObject(intersection); break;
@@ -794,20 +720,55 @@ EDITOR_TEMPLATE = '''
794
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
795
  const direction = (mode === 'raise') ? 1 : -1;
796
  const vertex = new THREE.Vector3();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
797
 
 
 
 
 
 
 
798
  for (let i = 0; i < positions.count; i++) {
799
  vertex.fromBufferAttribute(positions, i);
800
- const distance = vertex.distanceTo(center);
801
- if (distance < brushSize) {
802
- const falloff = Math.pow(1 - (distance / brushSize), 2);
803
- let currentY = positions.getY(i);
804
- let newY = currentY + direction * falloff * brushStrength;
805
  positions.setY(i, newY);
806
  }
807
  }
808
  positions.needsUpdate = true;
809
  terrainMesh.geometry.computeVertexNormals();
810
- terrainMesh.geometry.computeTangents();
811
  }
812
 
813
  function roughenTerrain(center) {
@@ -815,91 +776,66 @@ EDITOR_TEMPLATE = '''
815
  const brushSize = parseFloat(document.getElementById('brush-size').value);
816
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
817
  const vertex = new THREE.Vector3();
818
- const noiseFrequency = 1.5;
819
-
820
  for (let i = 0; i < positions.count; i++) {
821
  vertex.fromBufferAttribute(positions, i);
822
- const distance = vertex.distanceTo(center);
823
- if (distance < brushSize) {
824
- const falloff = Math.pow(1 - (distance / brushSize), 2);
825
- let noiseVal = noise2D(vertex.x * noiseFrequency, vertex.z * noiseFrequency);
826
- let currentY = positions.getY(i);
827
- let newY = currentY + noiseVal * falloff * brushStrength;
828
- positions.setY(i, newY);
829
  }
830
  }
831
  positions.needsUpdate = true;
832
  terrainMesh.geometry.computeVertexNormals();
833
- terrainMesh.geometry.computeTangents();
834
  }
835
 
836
  function paintTexture(center) {
837
  const colors = terrainMesh.geometry.attributes.color;
 
838
  const positions = terrainMesh.geometry.attributes.position;
839
  const brushSize = parseFloat(document.getElementById('brush-size').value);
840
  const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
841
- const textureType = document.querySelector('input[name="texture-type"]:checked').value;
842
-
843
- const targetColor = new THREE.Vector4(0,0,0,0);
844
- if(textureType === 'rock') targetColor.x = 1;
845
- else if (textureType === 'dirt') targetColor.y = 1;
846
- else if (textureType === 'snow') targetColor.z = 1;
847
- else if (textureType === 'sand') targetColor.w = 1;
848
 
849
  const vertex = new THREE.Vector3();
850
- const currentColor = new THREE.Vector4();
851
-
852
  for (let i = 0; i < positions.count; i++) {
853
  vertex.fromBufferAttribute(positions, i);
854
  const distance = vertex.distanceTo(center);
855
  if (distance < brushSize) {
856
- const falloff = Math.pow(1 - (distance / brushSize), 2);
857
- currentColor.fromBufferAttribute(colors, i);
858
-
859
- if (textureType === 'grass') {
860
- currentColor.x = Math.max(0, currentColor.x - falloff * brushStrength);
861
- currentColor.y = Math.max(0, currentColor.y - falloff * brushStrength);
862
- currentColor.z = Math.max(0, currentColor.z - falloff * brushStrength);
863
- currentColor.w = Math.max(0, currentColor.w - falloff * brushStrength);
 
864
  } else {
865
- currentColor.lerp(targetColor, falloff * brushStrength);
866
- }
867
-
868
- let sum = currentColor.x + currentColor.y + currentColor.z + currentColor.w;
869
- if (sum > 1.0) {
870
- currentColor.divideScalar(sum);
871
  }
872
- colors.setXYZW(i, currentColor.x, currentColor.y, currentColor.z, currentColor.w);
873
  }
874
  }
875
  colors.needsUpdate = true;
 
876
  }
877
 
878
  function placeObject(intersection) {
879
  const brushSize = parseFloat(document.getElementById('brush-size').value);
880
  const density = parseFloat(document.getElementById('brush-strength').value) * 2;
881
  const dummy = new THREE.Object3D();
882
-
883
  for (let i = 0; i < density; i++) {
884
  if (grassInstances.count >= MAX_GRASS_COUNT) break;
885
-
886
- const randomPoint = new THREE.Vector2(
887
- (Math.random() - 0.5) * brushSize * 2,
888
- (Math.random() - 0.5) * brushSize * 2
889
- );
890
-
891
- dummy.position.set(
892
- intersection.point.x + randomPoint.x,
893
- intersection.point.y,
894
- intersection.point.z + randomPoint.y
895
- );
896
-
897
- const placementRaycaster = new THREE.Raycaster(
898
- new THREE.Vector3(dummy.position.x, 100, dummy.position.z),
899
- new THREE.Vector3(0, -1, 0)
900
- );
901
  const placementIntersects = placementRaycaster.intersectObject(terrainMesh);
902
-
903
  if (placementIntersects.length > 0) {
904
  dummy.position.copy(placementIntersects[0].point);
905
  dummy.rotation.y = Math.random() * Math.PI * 2;
@@ -913,38 +849,30 @@ EDITOR_TEMPLATE = '''
913
 
914
  async function saveProject() {
915
  const projectName = document.getElementById('project-name').value.trim();
916
- if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
917
- if (!terrainMesh) { alert("Сначала создайте ландшафт для сохранения."); return; }
918
  showSpinner(true);
919
-
920
  const grassMatrices = [];
921
  for (let i = 0; i < grassInstances.count; i++) {
922
  const matrix = new THREE.Matrix4();
923
  grassInstances.getMatrixAt(i, matrix);
924
  grassMatrices.push(...matrix.elements);
925
  }
926
-
927
  const projectData = {
928
  name: projectName,
929
  width: terrainDimensions.width,
930
  height: terrainDimensions.height,
931
  heights: Array.from(terrainMesh.geometry.attributes.position.array.filter((_, i) => i % 3 === 1)),
932
  colors: Array.from(terrainMesh.geometry.attributes.color.array),
933
- foliage: {
934
- grass: grassMatrices
935
- }
 
936
  };
937
-
938
  try {
939
- const response = await fetch('/api/project', {
940
- method: 'POST',
941
- headers: { 'Content-Type': 'application/json' },
942
- body: JSON.stringify(projectData)
943
- });
944
  const result = await response.json();
945
- if (!response.ok) throw new Error(result.error || 'Ошибка при сохранении проекта.');
946
- alert(`Проект '${projectName}' успешно сохранен!`);
947
-
948
  const projectList = document.getElementById('project-list');
949
  if (!Array.from(projectList.options).some(opt => opt.value === projectName)) {
950
  const newOption = document.createElement('option');
@@ -952,7 +880,7 @@ EDITOR_TEMPLATE = '''
952
  projectList.appendChild(newOption);
953
  }
954
  } catch (error) {
955
- alert(`Не удалось сохранить проект: ${error.message}`);
956
  } finally {
957
  showSpinner(false);
958
  }
@@ -960,56 +888,54 @@ EDITOR_TEMPLATE = '''
960
 
961
  async function loadProject() {
962
  const projectName = document.getElementById('project-list').value;
963
- if (!projectName) { alert("Пожалуйста, выберите проект для загрузки."); return; }
964
  showSpinner(true);
965
-
966
  try {
967
  const response = await fetch(`/api/project/${projectName}`);
968
  const result = await response.json();
969
- if (!response.ok) throw new Error(result.error || 'Ошибка при загрузке проекта.');
 
 
 
 
 
970
 
 
 
 
 
 
 
 
 
 
971
  createTerrain(result.width, result.height, result);
972
  document.getElementById('project-name').value = projectName;
973
  document.getElementById('terrain-width').value = result.width;
974
  document.getElementById('terrain-height').value = result.height;
975
  } catch (error) {
976
- alert(`Не удалось загрузить проект: ${error.message}`);
977
  } finally {
978
  showSpinner(false);
979
  }
980
  }
981
 
982
  function updatePlayer(deltaTime) {
983
- if (!terrainMesh) return;
984
-
985
- let speedDelta = deltaTime * playerSpeed;
986
  const moveVector = new THREE.Vector3(0, 0, 0);
987
-
988
  if (keyStates['KeyW']) moveVector.z -= 1;
989
  if (keyStates['KeyS']) moveVector.z += 1;
990
  if (keyStates['KeyA']) moveVector.x -= 1;
991
  if (keyStates['KeyD']) moveVector.x += 1;
992
-
993
- if (playerOnGround && keyStates['Space']) {
994
- playerVelocity.y = playerJumpHeight;
995
- }
996
-
997
  playerVelocity.y += gravity * deltaTime;
998
-
999
- if (moveVector.length() > 0) {
1000
  moveVector.normalize().applyQuaternion(camera.quaternion).multiplyScalar(speedDelta);
1001
-
1002
- const collisionRay = new THREE.Raycaster(player.position, moveVector.clone().normalize());
1003
- const collisionResults = collisionRay.intersectObject(terrainMesh);
1004
-
1005
- if (collisionResults.length === 0 || collisionResults[0].distance > playerColliderRadius) {
1006
- player.position.x += moveVector.x;
1007
- player.position.z += moveVector.z;
1008
- }
1009
  }
1010
-
1011
  player.position.y += playerVelocity.y * deltaTime;
1012
-
1013
  raycaster.set(player.position, new THREE.Vector3(0, -1, 0));
1014
  const intersects = raycaster.intersectObject(terrainMesh);
1015
  if (intersects.length > 0) {
@@ -1022,25 +948,15 @@ EDITOR_TEMPLATE = '''
1022
  playerOnGround = false;
1023
  }
1024
  }
1025
-
1026
  camera.position.copy(player.position);
1027
  }
1028
 
1029
-
1030
  function animate() {
1031
  requestAnimationFrame(animate);
1032
  const deltaTime = clock.getDelta();
1033
- const elapsedTime = clock.getElapsedTime();
1034
-
1035
- if(grassMaterial) {
1036
- grassMaterial.uniforms.time.value = elapsedTime;
1037
- }
1038
-
1039
- if (isPlayMode && pointerLockControls.isLocked) {
1040
- updatePlayer(deltaTime);
1041
- } else {
1042
- orbitControls.update();
1043
- }
1044
  composer.render();
1045
  }
1046
 
 
1
+ Конечно, вот полный код проекта с реализованными улучшениями:Сохранение и загрузка замененных и новых текстур: Теперь пользовательские текстуры кодируются в формат Base64 и сохраняются непосредственно в файле проекта. При загрузке проекта они восстанавливаются.Создание новых слотов для текстур: Добавлена панель для создания новых слотов текстур. Вы можете задать имя, выбрать файл, и он появится в списке текстур для рисования. Система теперь поддерживает до 8 слоев текстур.Новые инструменты для ландшафта: Добавлены два новых режима кисти:Сгладить (Flatten): Усредняет высоту ландшафта под кистью, делая переходы более плавными. Запоминает высоту в точке первого клика.Выровнять (Level): Устанавливает высоту ландшафта под кистью на заданное значение. Появилось поле для ввода целевой высоты.Вот полный код: code Pythondownloadcontent_copyexpand_less import os
2
  import json
3
  import time
4
  from datetime import datetime
 
127
  button.play-button:hover { background: #33cc33; }
128
  .slider-container { margin-top: 10px; }
129
  input[type="range"] { width: 100%; }
130
+ .radio-group { display: flex; flex-wrap: wrap; }
131
+ .radio-group label { display: inline-block; margin-right: 10px; cursor: pointer; width: 45%; margin-bottom: 5px; }
132
  .radio-group input { margin-right: 5px; }
133
+ #level-brush-settings { display: none; }
134
 
135
  #loading-spinner {
136
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
 
204
  <div class="radio-group">
205
  <label><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
206
  <label><input type="radio" name="brush-mode" value="lower"> Опустить</label>
207
+ <label><input type="radio" name="brush-mode" value="flatten"> Сгладить</label>
208
+ <label><input type="radio" name="brush-mode" value="level"> Выровнять</label>
209
  <label><input type="radio" name="brush-mode" value="roughen"> Шум</label>
210
  <label><input type="radio" name="brush-mode" value="paint"> Текстура</label>
211
  <label><input type="radio" name="brush-mode" value="place"> Объект</label>
212
  </div>
213
+ <div id="level-brush-settings">
214
+ <label for="brush-level-height">Высота:</label>
215
+ <input type="number" id="brush-level-height" value="10">
216
+ </div>
217
  <div class="slider-container">
218
  <label for="brush-size">Размер: <span id="brush-size-value">10</span></label>
219
  <input type="range" id="brush-size" min="1" max="50" value="10">
 
226
  <div class="ui-group">
227
  <h3>Текстуры ландшафта</h3>
228
  <div class="radio-group" id="texture-selector">
 
 
 
 
 
229
  </div>
230
  </div>
231
  <div class="ui-group">
232
  <h3>Управление текстурами</h3>
233
+ <label for="texture-to-replace">Слот текстуры:</label>
234
  <select id="texture-to-replace">
 
 
 
 
 
235
  </select>
236
+ <label for="custom-texture-file">Заменить файл:</label>
237
  <input type="file" id="custom-texture-file" accept="image/*">
238
  <button id="update-texture-btn">Обновить текстуру</button>
239
  </div>
240
+ <div class="ui-group">
241
+ <h3>Новая текстура</h3>
242
+ <label for="new-texture-name">Имя слота (eng):</label>
243
+ <input type="text" id="new-texture-name" placeholder="lava">
244
+ <label for="new-texture-file">Файл текстуры:</label>
245
+ <input type="file" id="new-texture-file" accept="image/*">
246
+ <button id="add-new-texture-btn">Добавить новую текстуру</button>
247
+ </div>
248
  <div class="ui-group">
249
  <h3>Объекты</h3>
250
  <div class="radio-group" id="object-selector">
 
294
  let raycaster = new THREE.Raycaster();
295
  let mouse = new THREE.Vector2();
296
  let isEditing = false;
297
+ let flattenInitialHeight = 0;
298
  const noise2D = createNoise2D();
299
 
300
  let grassInstances, grassMaterial;
 
312
  const clock = new THREE.Clock();
313
 
314
  const textureLoader = new THREE.TextureLoader();
315
+ let textureSlots = {};
316
+ let textureDataUrls = {};
317
+ const MAX_TEXTURES = 8;
318
+
319
  const loadTexture = (url) => {
320
  const tex = textureLoader.load(url);
321
  tex.wrapS = THREE.RepeatWrapping;
 
324
  return tex;
325
  };
326
 
327
+ const defaultTextures = {
328
+ base: { name: 'Трава (База)', url: 'https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/grasslight-big.jpg' },
329
+ tex1: { name: 'Скалы', url: 'https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/rock.jpg' },
330
+ tex2: { name: 'Грунт', url: 'https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg' },
331
+ tex3: { name: 'Снег', url: 'https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/snow.jpg' },
332
+ tex4: { name: 'Песок', url: 'https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg' }
 
 
333
  };
334
+
335
+ function generateShaderUniforms() {
336
+ const uniforms = {
337
+ lightDirection: { value: new THREE.Vector3(0.5, 0.5, 0.5).normalize() }
338
+ };
339
+ for (let i = 0; i < MAX_TEXTURES; i++) {
340
+ uniforms[`texture${i}`] = { value: null };
341
+ }
342
+ return uniforms;
343
+ }
344
 
345
  const terrainMaterial = new THREE.ShaderMaterial({
346
+ uniforms: generateShaderUniforms(),
 
 
 
 
 
 
 
 
 
347
  vertexShader: `
348
  varying vec2 vUv;
349
  varying vec3 vNormal;
 
350
  attribute vec4 color;
351
+ attribute vec4 color2;
352
  varying vec4 vColor;
353
+ varying vec4 vColor2;
 
 
 
354
  void main() {
355
  vUv = uv;
356
  vColor = color;
357
+ vColor2 = color2;
 
358
  vNormal = normalize(normalMatrix * normal);
359
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
 
 
360
  }
361
  `,
362
  fragmentShader: `
363
+ ${Array.from({ length: MAX_TEXTURES }, (_, i) => `uniform sampler2D texture${i};`).join('\n')}
 
 
 
 
 
 
364
  uniform vec3 lightDirection;
 
365
  varying vec2 vUv;
366
  varying vec4 vColor;
367
+ varying vec4 vColor2;
368
  varying vec3 vNormal;
 
 
 
 
369
  void main() {
370
  vec2 uv_scaled = vUv * 30.0;
371
+ vec3 finalColor = texture2D(texture0, uv_scaled).rgb;
372
+ if (vColor.r > 0.0) finalColor = mix(finalColor, texture2D(texture1, uv_scaled).rgb, vColor.r);
373
+ if (vColor.g > 0.0) finalColor = mix(finalColor, texture2D(texture2, uv_scaled).rgb, vColor.g);
374
+ if (vColor.b > 0.0) finalColor = mix(finalColor, texture2D(texture3, uv_scaled).rgb, vColor.b);
375
+ if (vColor.a > 0.0) finalColor = mix(finalColor, texture2D(texture4, uv_scaled).rgb, vColor.a);
376
+ if (vColor2.r > 0.0) finalColor = mix(finalColor, texture2D(texture5, uv_scaled).rgb, vColor2.r);
377
+ if (vColor2.g > 0.0) finalColor = mix(finalColor, texture2D(texture6, uv_scaled).rgb, vColor2.g);
378
+ if (vColor2.b > 0.0) finalColor = mix(finalColor, texture2D(texture7, uv_scaled).rgb, vColor2.b);
379
 
380
+ float lighting = dot(vNormal, lightDirection) * 0.5 + 0.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  gl_FragColor = vec4(finalColor * lighting, 1.0);
382
  }
383
  `
 
385
 
386
  function init() {
387
  scene = new THREE.Scene();
 
388
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
389
  camera.position.set(50, 50, 50);
390
 
 
392
  renderer.setSize(window.innerWidth, window.innerHeight);
393
  renderer.setPixelRatio(window.devicePixelRatio);
394
  renderer.shadowMap.enabled = true;
 
395
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
396
  renderer.outputColorSpace = THREE.SRGBColorSpace;
397
  document.body.appendChild(renderer.domElement);
 
408
  dirLight.castShadow = true;
409
  dirLight.shadow.mapSize.width = 2048;
410
  dirLight.shadow.mapSize.height = 2048;
 
 
 
 
 
411
  scene.add(dirLight);
412
  terrainMaterial.uniforms.lightDirection.value = dirLight.position.clone().normalize();
413
 
 
415
  sky.scale.setScalar(450000);
416
  scene.add(sky);
417
  sun = new THREE.Vector3();
418
+ const effectController = { turbidity: 10, rayleigh: 3, mieCoefficient: 0.005, mieDirectionalG: 0.7, elevation: 4, azimuth: 180 };
 
 
 
 
 
 
 
419
  const uniforms = sky.material.uniforms;
420
+ uniforms['turbidity'].value = effectController.turbidity;
421
+ uniforms['rayleigh'].value = effectController.rayleigh;
422
+ uniforms['mieCoefficient'].value = effectController.mieCoefficient;
423
+ uniforms['mieDirectionalG'].value = effectController.mieDirectionalG;
424
+ const phi = THREE.MathUtils.degToRad(90 - effectController.elevation);
425
+ const theta = THREE.MathUtils.degToRad(effectController.azimuth);
426
+ sun.setFromSphericalCoords(1, phi, theta);
427
+ uniforms['sunPosition'].value.copy(sun);
428
  dirLight.position.copy(sun).multiplyScalar(100);
429
 
430
  const brushGeometry = new THREE.CylinderGeometry(1, 1, 100, 32, 1, true);
 
436
  initFoliage();
437
  initPostprocessing();
438
  initPlayer();
439
+ initializeTextureSlots();
 
 
 
 
 
 
440
  setupUIListeners();
441
  animate();
442
  }
443
 
444
  function initPostprocessing() {
445
  composer = new EffectComposer(renderer);
446
+ composer.addPass(new RenderPass(scene, camera));
 
 
447
  const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
448
  ssaoPass.kernelRadius = 16;
 
 
449
  composer.addPass(ssaoPass);
450
+ composer.addPass(new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.5, 0.4, 0.85));
 
 
451
  }
452
 
453
  function initFoliage() {
454
+ if (grassInstances) scene.remove(grassInstances);
 
 
 
 
455
  const grassTexture = textureLoader.load('https://threejs.org/examples/textures/sprites/grass.png');
456
  grassMaterial = new THREE.ShaderMaterial({
457
+ uniforms: { time: { value: 0 }, map: { value: grassTexture } },
 
 
 
458
  vertexShader: `
459
+ uniform float time; varying vec2 vUv;
 
 
460
  void main() {
461
+ vUv = uv; vec3 pos = position;
462
+ vec3 instancePosition = vec3(instanceMatrix[3][0], instanceMatrix[3][1], instanceMatrix[3][2]);
463
+ float windStrength = 0.2, windSpeed = 2.0;
 
 
 
 
 
464
  float noise = (sin(instancePosition.x * 0.5 + time * windSpeed) + cos(instancePosition.z * 0.5 + time * windSpeed)) * 0.5 + 0.5;
465
+ if (pos.y > 0.1) { pos.x += noise * windStrength; }
 
 
 
 
466
  gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(pos, 1.0);
467
  }
468
  `,
469
  fragmentShader: `
470
+ uniform sampler2D map; varying vec2 vUv;
 
 
471
  void main() {
472
  vec4 texColor = texture2D(map, vUv);
473
  if (texColor.a < 0.5) discard;
474
  gl_FragColor = texColor;
475
  }
476
  `,
477
+ side: THREE.DoubleSide, transparent: true
 
478
  });
479
 
480
  const grassBlade = new THREE.PlaneGeometry(0.8, 1.8);
 
488
  function initPlayer() {
489
  player = new THREE.Group();
490
  scene.add(player);
 
491
  pointerLockControls = new PointerLockControls(camera, document.body);
492
  const blocker = document.getElementById('blocker');
493
  const instructions = document.getElementById('instructions');
494
  instructions.addEventListener('click', () => pointerLockControls.lock());
495
  pointerLockControls.addEventListener('lock', () => { instructions.style.display = 'none'; blocker.style.display = 'none'; });
496
  pointerLockControls.addEventListener('unlock', () => { blocker.style.display = 'block'; instructions.style.display = ''; });
 
497
  document.addEventListener('keydown', (event) => { keyStates[event.code] = true; });
498
  document.addEventListener('keyup', (event) => { keyStates[event.code] = false; });
499
+ document.addEventListener('keydown', (event) => { if (isPlayMode && event.code === 'Escape') togglePlayMode(); });
 
 
 
 
500
  }
501
 
502
  function setupUIListeners() {
503
+ document.getElementById('create-terrain').addEventListener('click', () => createTerrain(parseInt(document.getElementById('terrain-width').value), parseInt(document.getElementById('terrain-height').value)));
 
 
 
 
504
  document.getElementById('save-project').addEventListener('click', saveProject);
505
  document.getElementById('load-project').addEventListener('click', loadProject);
506
+ document.getElementById('brush-size').addEventListener('input', (e) => { document.getElementById('brush-size-value').textContent = e.target.value; updateBrushHelper(); });
507
+ document.getElementById('brush-strength').addEventListener('input', (e) => { document.getElementById('brush-strength-value').textContent = e.target.value; });
508
+ document.getElementById('project-list').addEventListener('change', (e) => { document.getElementById('project-name').value = e.target.value; });
509
+ document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open'));
510
+ document.getElementById('clear-objects').addEventListener('click', () => { if (confirm('Вы уверены?')) { grassInstances.count = 0; grassInstances.instanceMatrix.needsUpdate = true; } });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
511
  document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
512
  document.getElementById('update-texture-btn').addEventListener('click', updateCustomTexture);
513
+ document.getElementById('add-new-texture-btn').addEventListener('click', addNewTextureSlot);
514
+ document.querySelectorAll('input[name="brush-mode"]').forEach(radio => radio.addEventListener('change', (e) => { document.getElementById('level-brush-settings').style.display = e.target.value === 'level' ? 'block' : 'none'; }));
515
  }
516
 
517
+ function initializeTextureSlots() {
518
+ addTextureSlot('base', defaultTextures.base.name, defaultTextures.base.url, false);
519
+ addTextureSlot('tex1', defaultTextures.tex1.name, defaultTextures.tex1.url);
520
+ addTextureSlot('tex2', defaultTextures.tex2.name, defaultTextures.tex2.url);
521
+ addTextureSlot('tex3', defaultTextures.tex3.name, defaultTextures.tex3.url);
522
+ addTextureSlot('tex4', defaultTextures.tex4.name, defaultTextures.tex4.url);
523
+ }
524
+
525
+ function addTextureSlot(id, name, urlOrData, isRemovable = true) {
526
+ if (Object.keys(textureSlots).length >= MAX_TEXTURES) {
527
+ alert(`Достигнут лимит текстур (${MAX_TEXTURES}).`);
528
  return;
529
  }
530
+ const index = Object.keys(textureSlots).length;
531
+ textureSlots[id] = { id, name, index };
532
 
533
+ const texture = loadTexture(urlOrData);
534
+ terrainMaterial.uniforms[`texture${index}`].value = texture;
535
+
536
+ if (urlOrData.startsWith('data:image')) {
537
+ textureDataUrls[id] = urlOrData;
538
+ }
539
+
540
+ const textureSelector = document.getElementById('texture-selector');
541
+ const label = document.createElement('label');
542
+ label.innerHTML = `<input type="radio" name="texture-type" value="${id}" data-index="${index}" ${index === 0 ? 'checked' : ''}> ${name}`;
543
+ textureSelector.appendChild(label);
544
+
545
+ const textureReplacer = document.getElementById('texture-to-replace');
546
+ const option = document.createElement('option');
547
+ option.value = id;
548
+ option.textContent = name;
549
+ textureReplacer.appendChild(option);
550
+ }
551
+
552
+ function fileToDataUrl(file) {
553
+ return new Promise((resolve, reject) => {
554
+ const reader = new FileReader();
555
+ reader.onload = () => resolve(reader.result);
556
+ reader.onerror = reject;
557
+ reader.readAsDataURL(file);
558
+ });
559
+ }
560
+
561
+ async function updateCustomTexture() {
562
+ const fileInput = document.getElementById('custom-texture-file');
563
+ const slotId = document.getElementById('texture-to-replace').value;
564
+ if (fileInput.files.length === 0) { alert('Пожалуйста, выберите файл.'); return; }
565
  const file = fileInput.files[0];
566
+ const dataUrl = await fileToDataUrl(file);
567
 
568
+ textureDataUrls[slotId] = dataUrl;
569
+ const newTexture = loadTexture(dataUrl);
570
+ const index = textureSlots[slotId].index;
571
+ terrainMaterial.uniforms[`texture${index}`].value.dispose();
572
+ terrainMaterial.uniforms[`texture${index}`].value = newTexture;
573
+ }
574
+
575
+ async function addNewTextureSlot() {
576
+ const nameInput = document.getElementById('new-texture-name');
577
+ const fileInput = document.getElementById('new-texture-file');
578
+ const slotId = nameInput.value.trim().toLowerCase().replace(/\s+/g, '_');
579
+ if (!slotId) { alert('Введите имя слота.'); return; }
580
+ if (textureSlots[slotId]) { alert('Слот с таким именем уже существует.'); return; }
581
+ if (fileInput.files.length === 0) { alert('Выберите файл текстуры.'); return; }
582
 
583
+ const file = fileInput.files[0];
584
+ const dataUrl = await fileToDataUrl(file);
585
 
586
+ addTextureSlot(slotId, nameInput.value.trim(), dataUrl);
587
+ nameInput.value = '';
588
+ fileInput.value = '';
 
 
 
 
 
 
589
  }
590
 
591
  function togglePlayMode() {
592
  isPlayMode = !isPlayMode;
593
  const toggleButton = document.getElementById('play-mode-toggle');
594
  const uiPanel = document.getElementById('ui-container');
 
595
  if (isPlayMode) {
596
  toggleButton.textContent = "Редактор";
 
597
  uiPanel.style.display = 'none';
598
  brushHelper.visible = false;
599
  orbitControls.enabled = false;
 
600
  const spawnRaycaster = new THREE.Raycaster(new THREE.Vector3(0, 100, 0), new THREE.Vector3(0, -1, 0));
601
  const intersects = spawnRaycaster.intersectObject(terrainMesh);
602
+ player.position.copy(intersects.length > 0 ? intersects[0].point : new THREE.Vector3(0,0,0));
 
 
 
 
603
  player.position.y += playerHeight;
604
  playerVelocity.set(0,0,0);
 
605
  pointerLockControls.lock();
 
606
  } else {
607
  toggleButton.textContent = "Играть";
 
608
  uiPanel.style.display = 'block';
609
  orbitControls.enabled = true;
610
  pointerLockControls.unlock();
 
616
  }
617
 
618
  function createTerrain(width, height, terrainData = null) {
619
+ if (terrainMesh) scene.remove(terrainMesh);
 
 
 
620
  initFoliage();
621
 
622
  terrainDimensions.width = width;
 
624
  terrainDimensions.segmentsX = Math.max(1, Math.round(width));
625
  terrainDimensions.segmentsY = Math.max(1, Math.round(height));
626
 
627
+ const geometry = new THREE.PlaneGeometry(width, height, terrainDimensions.segmentsX, terrainDimensions.segmentsY);
 
 
 
628
  geometry.rotateX(-Math.PI / 2);
629
 
630
+ geometry.setAttribute('color', new THREE.BufferAttribute(new Float32Array(geometry.attributes.position.count * 4), 4));
631
+ geometry.setAttribute('color2', new THREE.BufferAttribute(new Float32Array(geometry.attributes.position.count * 4), 4));
632
 
633
  if (terrainData) {
634
+ geometry.attributes.position.array.filter((_, i) => i % 3 === 1).forEach((_, i) => {
635
+ geometry.attributes.position.setY(i, terrainData.heights[i]);
636
+ });
637
+ if (terrainData.colors) geometry.attributes.color.array.set(terrainData.colors);
638
+ if (terrainData.colors2) geometry.attributes.color2.array.set(terrainData.colors2);
639
+ if (terrainData.foliage?.grass) {
 
 
640
  const matrices = terrainData.foliage.grass;
641
  grassInstances.count = matrices.length / 16;
642
  for (let i = 0; i < grassInstances.count; i++) {
643
+ grassInstances.setMatrixAt(i, new THREE.Matrix4().fromArray(matrices, i * 16));
 
 
644
  }
645
  grassInstances.instanceMatrix.needsUpdate = true;
646
  }
647
  }
648
 
 
649
  geometry.computeVertexNormals();
 
650
  terrainMesh = new THREE.Mesh(geometry, terrainMaterial);
651
  terrainMesh.castShadow = true;
652
  terrainMesh.receiveShadow = true;
 
660
  composer.setSize(window.innerWidth, window.innerHeight);
661
  }
662
 
663
+ function getBrushMode() { return document.querySelector('input[name="brush-mode"]:checked').value; }
 
 
664
 
665
  function onPointerMove(event) {
666
  if (isPlayMode) return;
667
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
668
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
 
669
  raycaster.setFromCamera(mouse, camera);
670
  if (!terrainMesh) return;
 
671
  const intersects = raycaster.intersectObject(terrainMesh);
 
672
  if (intersects.length > 0) {
673
+ brushHelper.position.copy(intersects[0].point).y = 0;
 
 
674
  brushHelper.visible = true;
675
  updateBrushHelper();
676
  orbitControls.enabled = !isEditing;
677
+ if (isEditing) applyBrush(intersects[0]);
 
 
 
678
  } else {
679
  brushHelper.visible = false;
680
  orbitControls.enabled = true;
 
687
  }
688
 
689
  function onPointerDown(event) {
690
+ if (isPlayMode || event.button !== 0 || mouse.x < -0.9) return;
691
+ isEditing = true;
692
+ orbitControls.enabled = false;
693
+ if (getBrushMode() === 'flatten') {
694
+ raycaster.setFromCamera(mouse, camera);
695
+ const intersects = raycaster.intersectObject(terrainMesh);
696
+ if (intersects.length > 0) flattenInitialHeight = intersects[0].point.y;
697
  }
698
  }
699
 
700
  function onPointerUp(event) {
701
+ if (event.button === 0) { isEditing = false; orbitControls.enabled = true; }
 
 
 
 
702
  }
703
 
704
  function applyBrush(intersection) {
 
706
  const brushMode = getBrushMode();
707
  switch(brushMode) {
708
  case 'raise': case 'lower': sculptTerrain(intersection.point, brushMode); break;
709
+ case 'flatten': flattenTerrain(intersection.point); break;
710
+ case 'level': levelTerrain(intersection.point); break;
711
  case 'roughen': roughenTerrain(intersection.point); break;
712
  case 'paint': paintTexture(intersection.point); break;
713
  case 'place': placeObject(intersection); break;
 
720
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
721
  const direction = (mode === 'raise') ? 1 : -1;
722
  const vertex = new THREE.Vector3();
723
+ for (let i = 0; i < positions.count; i++) {
724
+ vertex.fromBufferAttribute(positions, i);
725
+ const dist = vertex.distanceTo(center);
726
+ if (dist < brushSize) {
727
+ const falloff = Math.pow(1 - (dist / brushSize), 2);
728
+ positions.setY(i, positions.getY(i) + direction * falloff * brushStrength);
729
+ }
730
+ }
731
+ positions.needsUpdate = true;
732
+ terrainMesh.geometry.computeVertexNormals();
733
+ }
734
+
735
+ function flattenTerrain(center) {
736
+ const positions = terrainMesh.geometry.attributes.position;
737
+ const brushSize = parseFloat(document.getElementById('brush-size').value);
738
+ const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
739
+ const vertex = new THREE.Vector3();
740
+ for (let i = 0; i < positions.count; i++) {
741
+ vertex.fromBufferAttribute(positions, i);
742
+ const dist = vertex.distanceTo(center);
743
+ if (dist < brushSize) {
744
+ const falloff = Math.pow(1 - (dist / brushSize), 2);
745
+ const currentY = positions.getY(i);
746
+ const newY = THREE.MathUtils.lerp(currentY, flattenInitialHeight, falloff * brushStrength);
747
+ positions.setY(i, newY);
748
+ }
749
+ }
750
+ positions.needsUpdate = true;
751
+ terrainMesh.geometry.computeVertexNormals();
752
+ }
753
 
754
+ function levelTerrain(center) {
755
+ const positions = terrainMesh.geometry.attributes.position;
756
+ const brushSize = parseFloat(document.getElementById('brush-size').value);
757
+ const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
758
+ const targetHeight = parseFloat(document.getElementById('brush-level-height').value);
759
+ const vertex = new THREE.Vector3();
760
  for (let i = 0; i < positions.count; i++) {
761
  vertex.fromBufferAttribute(positions, i);
762
+ const dist = vertex.distanceTo(center);
763
+ if (dist < brushSize) {
764
+ const falloff = Math.pow(1 - (dist / brushSize), 2);
765
+ const currentY = positions.getY(i);
766
+ const newY = THREE.MathUtils.lerp(currentY, targetHeight, falloff * brushStrength);
767
  positions.setY(i, newY);
768
  }
769
  }
770
  positions.needsUpdate = true;
771
  terrainMesh.geometry.computeVertexNormals();
 
772
  }
773
 
774
  function roughenTerrain(center) {
 
776
  const brushSize = parseFloat(document.getElementById('brush-size').value);
777
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
778
  const vertex = new THREE.Vector3();
 
 
779
  for (let i = 0; i < positions.count; i++) {
780
  vertex.fromBufferAttribute(positions, i);
781
+ const dist = vertex.distanceTo(center);
782
+ if (dist < brushSize) {
783
+ const falloff = Math.pow(1 - (dist / brushSize), 2);
784
+ let noiseVal = noise2D(vertex.x * 1.5, vertex.z * 1.5);
785
+ positions.setY(i, positions.getY(i) + noiseVal * falloff * brushStrength);
 
 
786
  }
787
  }
788
  positions.needsUpdate = true;
789
  terrainMesh.geometry.computeVertexNormals();
 
790
  }
791
 
792
  function paintTexture(center) {
793
  const colors = terrainMesh.geometry.attributes.color;
794
+ const colors2 = terrainMesh.geometry.attributes.color2;
795
  const positions = terrainMesh.geometry.attributes.position;
796
  const brushSize = parseFloat(document.getElementById('brush-size').value);
797
  const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
798
+ const selectedTextureRadio = document.querySelector('input[name="texture-type"]:checked');
799
+ if (!selectedTextureRadio) return;
800
+ const textureIndex = parseInt(selectedTextureRadio.dataset.index);
 
 
 
 
801
 
802
  const vertex = new THREE.Vector3();
 
 
803
  for (let i = 0; i < positions.count; i++) {
804
  vertex.fromBufferAttribute(positions, i);
805
  const distance = vertex.distanceTo(center);
806
  if (distance < brushSize) {
807
+ const falloff = Math.pow(1 - (distance / brushSize), 2) * brushStrength;
808
+ if (textureIndex === 0) { // Base texture (grass)
809
+ colors.setX(i, Math.max(0, colors.getX(i) - falloff));
810
+ colors.setY(i, Math.max(0, colors.getY(i) - falloff));
811
+ colors.setZ(i, Math.max(0, colors.getZ(i) - falloff));
812
+ colors.setW(i, Math.max(0, colors.getW(i) - falloff));
813
+ colors2.setX(i, Math.max(0, colors2.getX(i) - falloff));
814
+ colors2.setY(i, Math.max(0, colors2.getY(i) - falloff));
815
+ colors2.setZ(i, Math.max(0, colors2.getZ(i) - falloff));
816
  } else {
817
+ let targetAttr, targetComponent;
818
+ if (textureIndex <= 4) { targetAttr = colors; targetComponent = textureIndex - 1; }
819
+ else { targetAttr = colors2; targetComponent = textureIndex - 5; }
820
+
821
+ let currentVal = targetAttr.getComponent(i, targetComponent);
822
+ targetAttr.setComponent(i, targetComponent, Math.min(1.0, currentVal + falloff));
823
  }
 
824
  }
825
  }
826
  colors.needsUpdate = true;
827
+ colors2.needsUpdate = true;
828
  }
829
 
830
  function placeObject(intersection) {
831
  const brushSize = parseFloat(document.getElementById('brush-size').value);
832
  const density = parseFloat(document.getElementById('brush-strength').value) * 2;
833
  const dummy = new THREE.Object3D();
 
834
  for (let i = 0; i < density; i++) {
835
  if (grassInstances.count >= MAX_GRASS_COUNT) break;
836
+ const randomPoint = new THREE.Vector2((Math.random() - 0.5) * brushSize * 2, (Math.random() - 0.5) * brushSize * 2);
837
+ const placementRaycaster = new THREE.Raycaster(new THREE.Vector3(intersection.point.x + randomPoint.x, 100, intersection.point.z + randomPoint.y), new THREE.Vector3(0, -1, 0));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  const placementIntersects = placementRaycaster.intersectObject(terrainMesh);
 
839
  if (placementIntersects.length > 0) {
840
  dummy.position.copy(placementIntersects[0].point);
841
  dummy.rotation.y = Math.random() * Math.PI * 2;
 
849
 
850
  async function saveProject() {
851
  const projectName = document.getElementById('project-name').value.trim();
852
+ if (!projectName || !terrainMesh) { alert("Введите имя проекта и создайте ландшафт."); return; }
 
853
  showSpinner(true);
 
854
  const grassMatrices = [];
855
  for (let i = 0; i < grassInstances.count; i++) {
856
  const matrix = new THREE.Matrix4();
857
  grassInstances.getMatrixAt(i, matrix);
858
  grassMatrices.push(...matrix.elements);
859
  }
 
860
  const projectData = {
861
  name: projectName,
862
  width: terrainDimensions.width,
863
  height: terrainDimensions.height,
864
  heights: Array.from(terrainMesh.geometry.attributes.position.array.filter((_, i) => i % 3 === 1)),
865
  colors: Array.from(terrainMesh.geometry.attributes.color.array),
866
+ colors2: Array.from(terrainMesh.geometry.attributes.color2.array),
867
+ textureDataUrls: textureDataUrls,
868
+ textureSlots: Object.values(textureSlots),
869
+ foliage: { grass: grassMatrices }
870
  };
 
871
  try {
872
+ const response = await fetch('/api/project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(projectData) });
 
 
 
 
873
  const result = await response.json();
874
+ if (!response.ok) throw new Error(result.error || 'Ошибка сохранения.');
875
+ alert(`Проект '${projectName}' сохранен!`);
 
876
  const projectList = document.getElementById('project-list');
877
  if (!Array.from(projectList.options).some(opt => opt.value === projectName)) {
878
  const newOption = document.createElement('option');
 
880
  projectList.appendChild(newOption);
881
  }
882
  } catch (error) {
883
+ alert(`Ошибка: ${error.message}`);
884
  } finally {
885
  showSpinner(false);
886
  }
 
888
 
889
  async function loadProject() {
890
  const projectName = document.getElementById('project-list').value;
891
+ if (!projectName) { alert("Выберите проект."); return; }
892
  showSpinner(true);
 
893
  try {
894
  const response = await fetch(`/api/project/${projectName}`);
895
  const result = await response.json();
896
+ if (!response.ok) throw new Error(result.error || 'Ошибка загрузки.');
897
+
898
+ document.getElementById('texture-selector').innerHTML = '';
899
+ document.getElementById('texture-to-replace').innerHTML = '';
900
+ textureSlots = {};
901
+ textureDataUrls = result.textureDataUrls || {};
902
 
903
+ if (result.textureSlots && result.textureSlots.length > 0) {
904
+ result.textureSlots.forEach(slot => {
905
+ const url = textureDataUrls[slot.id] || defaultTextures[slot.id]?.url;
906
+ if(url) addTextureSlot(slot.id, slot.name, url);
907
+ });
908
+ } else {
909
+ initializeTextureSlots();
910
+ }
911
+
912
  createTerrain(result.width, result.height, result);
913
  document.getElementById('project-name').value = projectName;
914
  document.getElementById('terrain-width').value = result.width;
915
  document.getElementById('terrain-height').value = result.height;
916
  } catch (error) {
917
+ alert(`Ошибка: ${error.message}`);
918
  } finally {
919
  showSpinner(false);
920
  }
921
  }
922
 
923
  function updatePlayer(deltaTime) {
924
+ if (!terrainMesh || !pointerLockControls.isLocked) return;
925
+ const speedDelta = deltaTime * playerSpeed;
 
926
  const moveVector = new THREE.Vector3(0, 0, 0);
 
927
  if (keyStates['KeyW']) moveVector.z -= 1;
928
  if (keyStates['KeyS']) moveVector.z += 1;
929
  if (keyStates['KeyA']) moveVector.x -= 1;
930
  if (keyStates['KeyD']) moveVector.x += 1;
931
+ if (playerOnGround && keyStates['Space']) playerVelocity.y = playerJumpHeight;
 
 
 
 
932
  playerVelocity.y += gravity * deltaTime;
933
+ if (moveVector.lengthSq() > 0) {
 
934
  moveVector.normalize().applyQuaternion(camera.quaternion).multiplyScalar(speedDelta);
935
+ player.position.x += moveVector.x;
936
+ player.position.z += moveVector.z;
 
 
 
 
 
 
937
  }
 
938
  player.position.y += playerVelocity.y * deltaTime;
 
939
  raycaster.set(player.position, new THREE.Vector3(0, -1, 0));
940
  const intersects = raycaster.intersectObject(terrainMesh);
941
  if (intersects.length > 0) {
 
948
  playerOnGround = false;
949
  }
950
  }
 
951
  camera.position.copy(player.position);
952
  }
953
 
 
954
  function animate() {
955
  requestAnimationFrame(animate);
956
  const deltaTime = clock.getDelta();
957
+ if(grassMaterial) grassMaterial.uniforms.time.value = clock.getElapsedTime();
958
+ if (isPlayMode) updatePlayer(deltaTime);
959
+ else orbitControls.update();
 
 
 
 
 
 
 
 
960
  composer.render();
961
  }
962