Kgshop commited on
Commit
67b98fc
·
verified ·
1 Parent(s): b614848

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +421 -197
app.py CHANGED
@@ -95,11 +95,11 @@ EDITOR_TEMPLATE = '''
95
  <head>
96
  <meta charset="UTF-8">
97
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
98
- <title>3D Level Designer</title>
99
  <style>
100
- body { margin: 0; overflow: hidden; background-color: #111; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
101
  canvas { display: block; }
102
- #ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; }
103
  #ui-panel {
104
  background: rgba(0, 0, 0, 0.75);
105
  padding: 15px;
@@ -110,23 +110,28 @@ EDITOR_TEMPLATE = '''
110
  box-sizing: border-box;
111
  transition: transform 0.3s ease-in-out;
112
  transform: translateX(0);
 
113
  }
114
  .ui-group { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #333; }
115
- .ui-group:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
116
  h3 { margin-top: 0; font-size: 1.1em; color: #00aaff; }
117
  label { display: block; margin-bottom: 5px; font-size: 0.9em; }
118
- input[type="number"], input[type="text"], select {
119
  width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px;
120
  }
121
  button {
122
- width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px;
123
  }
124
- button:hover { background: #0099ff; }
 
 
 
 
 
125
  .slider-container { margin-top: 10px; }
126
  input[type="range"] { width: 100%; }
127
  .radio-group label { display: inline-block; margin-right: 10px; cursor: pointer; }
128
  .radio-group input { margin-right: 5px; }
129
-
130
  #loading-spinner {
131
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
132
  border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
@@ -134,7 +139,7 @@ EDITOR_TEMPLATE = '''
134
  }
135
  #burger-menu {
136
  position: absolute; top: 15px; left: 15px; z-index: 20;
137
- display: none; width: 30px; height: 22px; cursor: pointer;
138
  }
139
  #burger-menu span {
140
  display: block; position: absolute; height: 4px; width: 100%;
@@ -145,6 +150,19 @@ EDITOR_TEMPLATE = '''
145
  #burger-menu span:nth-child(2) { top: 9px; }
146
  #burger-menu span:nth-child(3) { top: 18px; }
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
149
 
150
  @media (max-width: 800px) {
@@ -156,34 +174,33 @@ EDITOR_TEMPLATE = '''
156
  </head>
157
  <body>
158
  <div id="ui-container">
159
- <div id="burger-menu">
160
- <span></span><span></span><span></span>
161
- </div>
162
  <div id="ui-panel">
 
 
 
 
163
  <div class="ui-group">
164
  <h3>Проекты</h3>
165
- <label for="project-list">Загрузить проект:</label>
166
  <select id="project-list">
167
  <option value="">Выберите проект...</option>
168
  {% for project in projects %}
169
  <option value="{{ project }}">{{ project }}</option>
170
  {% endfor %}
171
  </select>
172
- <button id="load-project">Загрузить</button>
173
  <label for="project-name">Имя проекта:</label>
174
  <input type="text" id="project-name" placeholder="new-level-01">
175
- <button id="save-project">Сохранить</button>
176
  </div>
177
-
178
  <div class="ui-group">
179
  <h3>Ландшафт</h3>
180
- <label for="terrain-width">Ширина:</label>
181
- <input type="number" id="terrain-width" value="100">
182
- <label for="terrain-height">Длина:</label>
183
- <input type="number" id="terrain-height" value="100">
184
- <button id="create-terrain">Создать новый ландшафт</button>
185
  </div>
186
-
187
  <div class="ui-group">
188
  <h3>Кисть</h3>
189
  <div class="radio-group">
@@ -194,15 +211,14 @@ EDITOR_TEMPLATE = '''
194
  <label><input type="radio" name="brush-mode" value="place"> Объект</label>
195
  </div>
196
  <div class="slider-container">
197
- <label for="brush-size">Размер: <span id="brush-size-value">10</span></label>
198
- <input type="range" id="brush-size" min="1" max="50" value="10">
199
  </div>
200
  <div class="slider-container">
201
  <label for="brush-strength">Сила: <span id="brush-strength-value">0.5</span></label>
202
  <input type="range" id="brush-strength" min="0.1" max="2" step="0.1" value="0.5">
203
  </div>
204
  </div>
205
-
206
  <div class="ui-group">
207
  <h3>Текстуры</h3>
208
  <div class="radio-group" id="texture-selector">
@@ -213,18 +229,19 @@ EDITOR_TEMPLATE = '''
213
  <label><input type="radio" name="texture-type" value="sand"> Песок</label>
214
  </div>
215
  </div>
216
-
217
  <div class="ui-group">
218
  <h3>Объекты</h3>
219
  <div class="radio-group" id="object-selector">
220
  <label><input type="radio" name="object-type" value="grass" checked> Трава</label>
221
  </div>
222
- <button id="clear-objects">Очистить объекты</button>
223
  </div>
224
  </div>
225
  </div>
226
 
227
  <div id="loading-spinner"></div>
 
 
228
 
229
  <script type="importmap">
230
  {
@@ -235,144 +252,285 @@ EDITOR_TEMPLATE = '''
235
  }
236
  }
237
  </script>
238
-
239
  <script type="module">
240
  import * as THREE from 'three';
241
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
 
 
 
 
 
 
242
  import { createNoise2D } from 'simplex-noise';
243
 
244
- let scene, camera, renderer, controls, terrainMesh, brushHelper;
 
245
  let raycaster = new THREE.Raycaster();
246
  let mouse = new THREE.Vector2();
247
- let isInteracting = false;
248
  const noise2D = createNoise2D();
249
-
 
250
  let grassInstances;
251
- const MAX_GRASS_COUNT = 50000;
 
 
 
 
 
 
 
 
 
 
 
252
 
253
- const terrainDimensions = { width: 100, height: 100, segmentsX: 100, segmentsY: 100 };
254
-
255
  const textureLoader = new THREE.TextureLoader();
256
- const textures = {
257
- grass: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/grasslight-big.jpg'),
258
- rock: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/rock.jpg'),
259
- dirt: textureLoader.load('https://junior3d.ru/texture/%D0%97%D0%B5%D0%BC%D0%BB%D1%8F/%D0%93%D1%80%D1%83%D0%BD%D1%82/%D0%B3%D1%80%D1%83%D0%BD%D1%82_59.jpg'),
260
- snow: textureLoader.load('https://media.istockphoto.com/id/172725391/it/foto/tessuto-da-neve.jpg?s=170667a&w=0&k=20&c=FF0U8Fygybj-Qq1SPDOIhsqAqj_4GhTOTFK560TMxyA='),
261
- sand: textureLoader.load('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg')
 
262
  };
263
- Object.values(textures).forEach(tex => {
264
- tex.wrapS = THREE.RepeatWrapping;
265
- tex.wrapT = THREE.RepeatWrapping;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  });
267
 
268
- const terrainMaterial = new THREE.ShaderMaterial({
269
- uniforms: {
270
- grassTexture: { value: textures.grass },
271
- rockTexture: { value: textures.rock },
272
- dirtTexture: { value: textures.dirt },
273
- snowTexture: { value: textures.snow },
274
- sandTexture: { value: textures.sand }
275
- },
276
- vertexShader: `
 
 
 
 
 
 
 
 
 
 
 
277
  varying vec2 vUv;
278
- varying vec3 vNormal;
279
- attribute vec4 color;
280
  varying vec4 vColor;
281
- void main() {
282
- vUv = uv;
283
- vNormal = normal;
284
- vColor = color;
285
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
286
- }
287
- `,
288
- fragmentShader: `
289
- uniform sampler2D grassTexture;
290
- uniform sampler2D rockTexture;
291
- uniform sampler2D dirtTexture;
292
- uniform sampler2D snowTexture;
293
- uniform sampler2D sandTexture;
294
  varying vec2 vUv;
295
- varying vec3 vNormal;
296
  varying vec4 vColor;
297
- void main() {
298
- vec2 uv_scaled = vUv * 30.0;
299
- vec4 grass = texture2D(grassTexture, uv_scaled);
300
- vec4 rock = texture2D(rockTexture, uv_scaled);
301
- vec4 dirt = texture2D(dirtTexture, uv_scaled);
302
- vec4 snow = texture2D(snowTexture, uv_scaled);
303
- vec4 sand = texture2D(sandTexture, uv_scaled);
304
-
305
- vec3 finalColor = grass.rgb;
306
- finalColor = mix(finalColor, rock.rgb, vColor.r);
307
- finalColor = mix(finalColor, dirt.rgb, vColor.g);
308
- finalColor = mix(finalColor, snow.rgb, vColor.b);
309
- finalColor = mix(finalColor, sand.rgb, vColor.a);
310
-
311
- float lighting = dot(vNormal, normalize(vec3(1.0, 1.0, 0.5))) * 0.5 + 0.5;
312
- gl_FragColor = vec4(finalColor * lighting, 1.0);
 
 
 
 
 
313
  }
314
- `
315
- });
316
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  function init() {
318
  scene = new THREE.Scene();
319
- scene.background = new THREE.Color(0x87ceeb);
320
- scene.fog = new THREE.Fog(0x87ceeb, 200, 500);
321
-
322
- camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
323
  camera.position.set(50, 50, 50);
324
 
325
  renderer = new THREE.WebGLRenderer({ antialias: true });
326
  renderer.setSize(window.innerWidth, window.innerHeight);
327
  renderer.setPixelRatio(window.devicePixelRatio);
328
  renderer.shadowMap.enabled = true;
 
 
 
329
  document.body.appendChild(renderer.domElement);
330
 
331
- controls = new OrbitControls(camera, renderer.domElement);
332
- controls.enableDamping = true;
333
- controls.maxPolarAngle = Math.PI / 2.1;
334
 
335
- const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.5);
336
- hemiLight.position.set(0, 200, 0);
 
 
 
 
 
 
 
 
 
 
337
  scene.add(hemiLight);
338
 
339
  const dirLight = new THREE.DirectionalLight(0xffffff, 2.5);
340
  dirLight.position.set(100, 100, 50);
341
  dirLight.castShadow = true;
342
- dirLight.shadow.camera.top = 100;
343
- dirLight.shadow.camera.bottom = -100;
344
- dirLight.shadow.camera.left = -100;
345
- dirLight.shadow.camera.right = 100;
 
 
 
346
  scene.add(dirLight);
347
 
348
- const brushGeometry = new THREE.CylinderGeometry(1, 1, 100, 32, 1, true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  const brushMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, transparent: true, opacity: 0.5 });
350
  brushHelper = new THREE.Mesh(brushGeometry, brushMaterial);
 
351
  brushHelper.visible = false;
352
  scene.add(brushHelper);
353
 
 
 
 
 
 
 
354
  initFoliage();
355
 
 
 
 
 
 
 
 
 
 
356
  window.addEventListener('resize', onWindowResize);
357
  renderer.domElement.addEventListener('pointermove', onPointerMove);
358
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
359
  renderer.domElement.addEventListener('pointerup', onPointerUp);
360
  renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
361
 
 
 
 
362
  setupUIListeners();
363
  animate();
364
  }
365
 
366
  function initFoliage() {
367
- if (grassInstances) {
368
- scene.remove(grassInstances);
369
- grassInstances.geometry.dispose();
370
- grassInstances.material.dispose();
371
- }
372
- const grassBlade = new THREE.PlaneGeometry(0.2, 1.5);
373
- const grassMaterial = new THREE.MeshBasicMaterial({ color: 0x339933, side: THREE.DoubleSide, alphaTest: 0.5 });
374
  grassInstances = new THREE.InstancedMesh(grassBlade, grassMaterial, MAX_GRASS_COUNT);
375
  grassInstances.count = 0;
 
376
  scene.add(grassInstances);
377
  }
378
 
@@ -382,16 +540,17 @@ EDITOR_TEMPLATE = '''
382
  const height = parseInt(document.getElementById('terrain-height').value);
383
  createTerrain(width, height);
384
  });
 
385
  document.getElementById('save-project').addEventListener('click', saveProject);
386
  document.getElementById('load-project').addEventListener('click', loadProject);
387
- document.getElementById('brush-size').addEventListener('input', (e) => {
388
  document.getElementById('brush-size-value').textContent = e.target.value;
389
  updateBrushHelper();
390
  });
391
- document.getElementById('brush-strength').addEventListener('input', (e) => {
392
  document.getElementById('brush-strength-value').textContent = e.target.value;
393
  });
394
- document.getElementById('project-list').addEventListener('change', (e) => {
395
  document.getElementById('project-name').value = e.target.value;
396
  });
397
  document.getElementById('burger-menu').addEventListener('click', () => {
@@ -405,9 +564,44 @@ EDITOR_TEMPLATE = '''
405
  });
406
  }
407
 
408
- function showSpinner(show) {
409
- document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  }
 
 
411
 
412
  function createTerrain(width, height, terrainData = null) {
413
  if (terrainMesh) {
@@ -418,8 +612,8 @@ EDITOR_TEMPLATE = '''
418
 
419
  terrainDimensions.width = width;
420
  terrainDimensions.height = height;
421
- terrainDimensions.segmentsX = Math.max(1, Math.round(width));
422
- terrainDimensions.segmentsY = Math.max(1, Math.round(height));
423
 
424
  const geometry = new THREE.PlaneGeometry(
425
  terrainDimensions.width, terrainDimensions.height,
@@ -451,7 +645,6 @@ EDITOR_TEMPLATE = '''
451
  }
452
 
453
  terrainMesh = new THREE.Mesh(geometry, terrainMaterial);
454
- terrainMesh.castShadow = true;
455
  terrainMesh.receiveShadow = true;
456
  scene.add(terrainMesh);
457
 
@@ -462,13 +655,13 @@ EDITOR_TEMPLATE = '''
462
  camera.aspect = window.innerWidth / window.innerHeight;
463
  camera.updateProjectionMatrix();
464
  renderer.setSize(window.innerWidth, window.innerHeight);
 
465
  }
466
 
467
- function getBrushMode() {
468
- return document.querySelector('input[name="brush-mode"]:checked').value;
469
- }
470
 
471
  function onPointerMove(event) {
 
472
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
473
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
474
 
@@ -479,18 +672,16 @@ EDITOR_TEMPLATE = '''
479
 
480
  if (intersects.length > 0) {
481
  const intersectionPoint = intersects[0].point;
482
- brushHelper.position.copy(intersectionPoint);
483
- brushHelper.position.y = 0;
484
  brushHelper.visible = true;
485
  updateBrushHelper();
486
- controls.enabled = !isInteracting;
487
 
488
- if (isInteracting) {
489
- applyBrush(intersects[0]);
490
- }
491
  } else {
492
  brushHelper.visible = false;
493
- controls.enabled = true;
494
  }
495
  }
496
 
@@ -500,39 +691,51 @@ EDITOR_TEMPLATE = '''
500
  }
501
 
502
  function onPointerDown(event) {
503
- if (event.button === 0 && mouse.x > -0.9) {
504
  if (window.innerWidth < 800 && !document.getElementById('ui-panel').contains(event.target)) {
505
  document.getElementById('ui-panel').classList.remove('open');
506
  }
507
  isInteracting = true;
508
- controls.enabled = false;
509
  }
510
  }
511
 
512
  function onPointerUp(event) {
513
- if (event.button === 0) {
514
  isInteracting = false;
515
- controls.enabled = true;
516
  }
517
  }
518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  function applyBrush(intersection) {
520
  if (!terrainMesh) return;
521
  const brushMode = getBrushMode();
522
  switch(brushMode) {
523
- case 'raise':
524
- case 'lower':
525
- sculptTerrain(intersection.point, brushMode);
526
- break;
527
- case 'roughen':
528
- roughenTerrain(intersection.point);
529
- break;
530
- case 'paint':
531
- paintTexture(intersection.point);
532
- break;
533
- case 'place':
534
- placeObject(intersection);
535
- break;
536
  }
537
  }
538
 
@@ -545,12 +748,10 @@ EDITOR_TEMPLATE = '''
545
 
546
  for (let i = 0; i < positions.count; i++) {
547
  vertex.fromBufferAttribute(positions, i);
548
- const distance = vertex.distanceTo(center);
549
- if (distance < brushSize) {
550
- const falloff = Math.pow(1 - (distance / brushSize), 2);
551
- let currentY = positions.getY(i);
552
- let newY = currentY + direction * falloff * brushStrength;
553
- positions.setY(i, newY);
554
  }
555
  }
556
  positions.needsUpdate = true;
@@ -562,17 +763,14 @@ EDITOR_TEMPLATE = '''
562
  const brushSize = parseFloat(document.getElementById('brush-size').value);
563
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
564
  const vertex = new THREE.Vector3();
565
- const noiseFrequency = 1.5;
566
 
567
  for (let i = 0; i < positions.count; i++) {
568
  vertex.fromBufferAttribute(positions, i);
569
- const distance = vertex.distanceTo(center);
570
- if (distance < brushSize) {
571
- const falloff = Math.pow(1 - (distance / brushSize), 2);
572
- let noiseVal = noise2D(vertex.x * noiseFrequency, vertex.z * noiseFrequency);
573
- let currentY = positions.getY(i);
574
- let newY = currentY + noiseVal * falloff * brushStrength;
575
- positions.setY(i, newY);
576
  }
577
  }
578
  positions.needsUpdate = true;
@@ -587,34 +785,30 @@ EDITOR_TEMPLATE = '''
587
  const textureType = document.querySelector('input[name="texture-type"]:checked').value;
588
 
589
  const targetColor = new THREE.Vector4(0,0,0,0);
590
- if(textureType === 'rock') targetColor.x = 1;
591
- else if (textureType === 'dirt') targetColor.y = 1;
592
- else if (textureType === 'snow') targetColor.z = 1;
593
- else if (textureType === 'sand') targetColor.w = 1;
594
 
595
  const vertex = new THREE.Vector3();
596
  const currentColor = new THREE.Vector4();
597
 
598
  for (let i = 0; i < positions.count; i++) {
599
  vertex.fromBufferAttribute(positions, i);
600
- const distance = vertex.distanceTo(center);
601
- if (distance < brushSize) {
602
- const falloff = Math.pow(1 - (distance / brushSize), 2);
603
  currentColor.fromBufferAttribute(colors, i);
604
 
605
  if (textureType === 'grass') {
606
- currentColor.x = Math.max(0, currentColor.x - falloff * brushStrength);
607
- currentColor.y = Math.max(0, currentColor.y - falloff * brushStrength);
608
- currentColor.z = Math.max(0, currentColor.z - falloff * brushStrength);
609
- currentColor.w = Math.max(0, currentColor.w - falloff * brushStrength);
610
  } else {
 
611
  currentColor.lerp(targetColor, falloff * brushStrength);
612
  }
613
 
614
  let sum = currentColor.x + currentColor.y + currentColor.z + currentColor.w;
615
- if (sum > 1.0) {
616
- currentColor.divideScalar(sum);
617
- }
618
 
619
  colors.setXYZW(i, currentColor.x, currentColor.y, currentColor.z, currentColor.w);
620
  }
@@ -624,26 +818,20 @@ EDITOR_TEMPLATE = '''
624
 
625
  function placeObject(intersection) {
626
  const brushSize = parseFloat(document.getElementById('brush-size').value);
627
- const density = parseFloat(document.getElementById('brush-strength').value) * 2;
628
  const dummy = new THREE.Object3D();
629
 
630
  for (let i = 0; i < density; i++) {
631
  if (grassInstances.count >= MAX_GRASS_COUNT) break;
632
 
633
- const randomPoint = new THREE.Vector2(
634
- (Math.random() - 0.5) * brushSize * 2,
635
- (Math.random() - 0.5) * brushSize * 2
636
- );
637
 
638
- dummy.position.set(
639
- intersection.point.x + randomPoint.x,
640
- intersection.point.y,
641
- intersection.point.z + randomPoint.y
642
- );
643
 
644
  const placementRaycaster = new THREE.Raycaster(
645
- new THREE.Vector3(dummy.position.x, 50, dummy.position.z),
646
- new THREE.Vector3(0, -1, 0)
647
  );
648
  const placementIntersects = placementRaycaster.intersectObject(terrainMesh);
649
 
@@ -660,26 +848,22 @@ EDITOR_TEMPLATE = '''
660
 
661
  async function saveProject() {
662
  const projectName = document.getElementById('project-name').value.trim();
663
- if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
664
- if (!terrainMesh) { alert("Сначала создайте ландшафт для сохранения."); return; }
665
  showSpinner(true);
666
 
667
  const grassMatrices = [];
 
668
  for (let i = 0; i < grassInstances.count; i++) {
669
- const matrix = new THREE.Matrix4();
670
  grassInstances.getMatrixAt(i, matrix);
671
  grassMatrices.push(...matrix.elements);
672
  }
673
 
674
  const projectData = {
675
  name: projectName,
676
- width: terrainDimensions.width,
677
- height: terrainDimensions.height,
678
  heights: Array.from(terrainMesh.geometry.attributes.position.array.filter((_, i) => i % 3 === 1)),
679
  colors: Array.from(terrainMesh.geometry.attributes.color.array),
680
- foliage: {
681
- grass: grassMatrices
682
- }
683
  };
684
 
685
  try {
@@ -689,11 +873,11 @@ EDITOR_TEMPLATE = '''
689
  body: JSON.stringify(projectData)
690
  });
691
  const result = await response.json();
692
- if (!response.ok) throw new Error(result.error || 'Ошибка при сохранении проекта.');
693
  alert(`Проект '${projectName}' успешно сохранен!`);
694
 
695
  const projectList = document.getElementById('project-list');
696
- if (!Array.from(projectList.options).some(opt => opt.value === projectName)) {
697
  const newOption = document.createElement('option');
698
  newOption.value = newOption.textContent = projectName;
699
  projectList.appendChild(newOption);
@@ -707,13 +891,13 @@ EDITOR_TEMPLATE = '''
707
 
708
  async function loadProject() {
709
  const projectName = document.getElementById('project-list').value;
710
- if (!projectName) { alert("Пожалуйста, выберите проект для загрузки."); return; }
711
  showSpinner(true);
712
 
713
  try {
714
  const response = await fetch(`/api/project/${projectName}`);
715
  const result = await response.json();
716
- if (!response.ok) throw new Error(result.error || 'Ошибка при загрузке прое��та.');
717
 
718
  createTerrain(result.width, result.height, result);
719
  document.getElementById('project-name').value = projectName;
@@ -726,14 +910,54 @@ EDITOR_TEMPLATE = '''
726
  }
727
  }
728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  function animate() {
730
  requestAnimationFrame(animate);
731
- controls.update();
732
- renderer.render(scene, camera);
 
 
 
 
 
 
 
733
  }
734
 
 
735
  init();
736
- createTerrain(100, 100);
737
  </script>
738
  </body>
739
  </html>
 
95
  <head>
96
  <meta charset="UTF-8">
97
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
98
+ <title>AAA Level Designer</title>
99
  <style>
100
+ body { margin: 0; overflow: hidden; background-color: #000; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
101
  canvas { display: block; }
102
+ #ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; }
103
  #ui-panel {
104
  background: rgba(0, 0, 0, 0.75);
105
  padding: 15px;
 
110
  box-sizing: border-box;
111
  transition: transform 0.3s ease-in-out;
112
  transform: translateX(0);
113
+ pointer-events: auto;
114
  }
115
  .ui-group { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #333; }
116
+ .ui-group:last-child { border-bottom: none; }
117
  h3 { margin-top: 0; font-size: 1.1em; color: #00aaff; }
118
  label { display: block; margin-bottom: 5px; font-size: 0.9em; }
119
+ input, select {
120
  width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px;
121
  }
122
  button {
123
+ width: 100%; padding: 10px; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px;
124
  }
125
+ .primary-button { background: #0077cc; }
126
+ .primary-button:hover { background: #0099ff; }
127
+ .secondary-button { background: #555; }
128
+ .secondary-button:hover { background: #777; }
129
+ .play-button { background: #28a745; }
130
+ .play-button:hover { background: #218838; }
131
  .slider-container { margin-top: 10px; }
132
  input[type="range"] { width: 100%; }
133
  .radio-group label { display: inline-block; margin-right: 10px; cursor: pointer; }
134
  .radio-group input { margin-right: 5px; }
 
135
  #loading-spinner {
136
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
137
  border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
 
139
  }
140
  #burger-menu {
141
  position: absolute; top: 15px; left: 15px; z-index: 20;
142
+ display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
143
  }
144
  #burger-menu span {
145
  display: block; position: absolute; height: 4px; width: 100%;
 
150
  #burger-menu span:nth-child(2) { top: 9px; }
151
  #burger-menu span:nth-child(3) { top: 18px; }
152
 
153
+ #play-mode-ui {
154
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
155
+ color: white; font-size: 2em; text-shadow: 2px 2px 4px #000;
156
+ display: none; pointer-events: none;
157
+ }
158
+ #play-mode-ui span { font-size: 5em; }
159
+
160
+ #play-instructions {
161
+ position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
162
+ background: rgba(0,0,0,0.5); padding: 10px 20px; border-radius: 10px;
163
+ display: none; pointer-events: none;
164
+ }
165
+
166
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
167
 
168
  @media (max-width: 800px) {
 
174
  </head>
175
  <body>
176
  <div id="ui-container">
177
+ <div id="burger-menu"><span></span><span></span><span></span></div>
 
 
178
  <div id="ui-panel">
179
+ <div class="ui-group">
180
+ <h3>Игровой режим</h3>
181
+ <button id="play-button" class="play-button">Запустить игру</button>
182
+ </div>
183
  <div class="ui-group">
184
  <h3>Проекты</h3>
 
185
  <select id="project-list">
186
  <option value="">Выберите проект...</option>
187
  {% for project in projects %}
188
  <option value="{{ project }}">{{ project }}</option>
189
  {% endfor %}
190
  </select>
191
+ <button id="load-project" class="primary-button">Загрузить</button>
192
  <label for="project-name">Имя проекта:</label>
193
  <input type="text" id="project-name" placeholder="new-level-01">
194
+ <button id="save-project" class="primary-button">Сохранить</button>
195
  </div>
 
196
  <div class="ui-group">
197
  <h3>Ландшафт</h3>
198
+ <label for="terrain-width">Ширина (X):</label>
199
+ <input type="number" id="terrain-width" value="256">
200
+ <label for="terrain-height">Длина (Z):</label>
201
+ <input type="number" id="terrain-height" value="256">
202
+ <button id="create-terrain" class="secondary-button">Создать новый ландшафт</button>
203
  </div>
 
204
  <div class="ui-group">
205
  <h3>Кисть</h3>
206
  <div class="radio-group">
 
211
  <label><input type="radio" name="brush-mode" value="place"> Объект</label>
212
  </div>
213
  <div class="slider-container">
214
+ <label for="brush-size">Размер: <span id="brush-size-value">15</span></label>
215
+ <input type="range" id="brush-size" min="1" max="50" value="15">
216
  </div>
217
  <div class="slider-container">
218
  <label for="brush-strength">Сила: <span id="brush-strength-value">0.5</span></label>
219
  <input type="range" id="brush-strength" min="0.1" max="2" step="0.1" value="0.5">
220
  </div>
221
  </div>
 
222
  <div class="ui-group">
223
  <h3>Текстуры</h3>
224
  <div class="radio-group" id="texture-selector">
 
229
  <label><input type="radio" name="texture-type" value="sand"> Песок</label>
230
  </div>
231
  </div>
 
232
  <div class="ui-group">
233
  <h3>Объекты</h3>
234
  <div class="radio-group" id="object-selector">
235
  <label><input type="radio" name="object-type" value="grass" checked> Трава</label>
236
  </div>
237
+ <button id="clear-objects" class="secondary-button">Очистить объекты</button>
238
  </div>
239
  </div>
240
  </div>
241
 
242
  <div id="loading-spinner"></div>
243
+ <div id="play-mode-ui"><span>+</span></div>
244
+ <div id="play-instructions">W, A, S, D - Движение | SPACE - Прыжок | ESC - Выход</div>
245
 
246
  <script type="importmap">
247
  {
 
252
  }
253
  }
254
  </script>
 
255
  <script type="module">
256
  import * as THREE from 'three';
257
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
258
+ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
259
+ import { Sky } from 'three/addons/objects/Sky.js';
260
+ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
261
+ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
262
+ import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
263
+ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
264
+ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
265
  import { createNoise2D } from 'simplex-noise';
266
 
267
+ let scene, camera, renderer, editorControls, playControls, composer;
268
+ let terrainMesh, brushHelper, player, sky, sun;
269
  let raycaster = new THREE.Raycaster();
270
  let mouse = new THREE.Vector2();
271
+ let isInteracting = false, isPlayMode = false;
272
  const noise2D = createNoise2D();
273
+ const clock = new THREE.Clock();
274
+
275
  let grassInstances;
276
+ const MAX_GRASS_COUNT = 100000;
277
+ const terrainDimensions = { width: 256, height: 256, segmentsX: 256, segmentsY: 256 };
278
+
279
+ const playerVelocity = new THREE.Vector3();
280
+ const playerDirection = new THREE.Vector3();
281
+ const PLAYER_HEIGHT = 2.0;
282
+ const GRAVITY = -20;
283
+ const PLAYER_SPEED = 20;
284
+ const JUMP_FORCE = 8;
285
+ let onGround = false;
286
+
287
+ const moveState = { forward: 0, right: 0 };
288
 
 
 
289
  const textureLoader = new THREE.TextureLoader();
290
+ const textures = {};
291
+ const pbrTextureUrls = {
292
+ grass: { map: 'https://ambientcg.com/get/GenericGrass002_2K_Color.jpg', normal: 'https://ambientcg.com/get/GenericGrass002_2K_NormalGL.jpg', rough: 'https://ambientcg.com/get/GenericGrass002_2K_Roughness.jpg'},
293
+ rock: { map: 'https://ambientcg.com/get/Rock030_2K_Color.jpg', normal: 'https://ambientcg.com/get/Rock030_2K_NormalGL.jpg', rough: 'https://ambientcg.com/get/Rock030_2K_Roughness.jpg'},
294
+ dirt: { map: 'https://ambientcg.com/get/Ground037_2K_Color.jpg', normal: 'https://ambientcg.com/get/Ground037_2K_NormalGL.jpg', rough: 'https://ambientcg.com/get/Ground037_2K_Roughness.jpg'},
295
+ snow: { map: 'https://ambientcg.com/get/Snow006_2K_Color.jpg', normal: 'https://ambientcg.com/get/Snow006_2K_NormalGL.jpg', rough: 'https://ambientcg.com/get/Snow006_2K_Roughness.jpg'},
296
+ sand: { map: 'https://ambientcg.com/get/Sand004_2K_Color.jpg', normal: 'https://ambientcg.com/get/Sand004_2K_NormalGL.jpg', rough: 'https://ambientcg.com/get/Sand004_2K_Roughness.jpg'},
297
  };
298
+
299
+ function loadTextures() {
300
+ for (const key in pbrTextureUrls) {
301
+ textures[key] = {
302
+ map: textureLoader.load(pbrTextureUrls[key].map),
303
+ normalMap: textureLoader.load(pbrTextureUrls[key].normal),
304
+ roughnessMap: textureLoader.load(pbrTextureUrls[key].rough),
305
+ };
306
+ Object.values(textures[key]).forEach(tex => {
307
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
308
+ tex.colorSpace = THREE.SRGBColorSpace;
309
+ });
310
+ }
311
+ }
312
+
313
+ const terrainMaterial = new THREE.MeshStandardMaterial({
314
+ vertexColors: true,
315
+ roughness: 0.8,
316
+ metalness: 0.2,
317
  });
318
 
319
+ terrainMaterial.onBeforeCompile = shader => {
320
+ shader.uniforms.grassMap = { value: textures.grass.map };
321
+ shader.uniforms.rockMap = { value: textures.rock.map };
322
+ shader.uniforms.dirtMap = { value: textures.dirt.map };
323
+ shader.uniforms.snowMap = { value: textures.snow.map };
324
+ shader.uniforms.sandMap = { value: textures.sand.map };
325
+
326
+ shader.uniforms.grassNormal = { value: textures.grass.normalMap };
327
+ shader.uniforms.rockNormal = { value: textures.rock.normalMap };
328
+ shader.uniforms.dirtNormal = { value: textures.dirt.normalMap };
329
+ shader.uniforms.snowNormal = { value: textures.snow.normalMap };
330
+ shader.uniforms.sandNormal = { value: textures.sand.normalMap };
331
+
332
+ shader.uniforms.grassRough = { value: textures.grass.roughnessMap };
333
+ shader.uniforms.rockRough = { value: textures.rock.roughnessMap };
334
+ shader.uniforms.dirtRough = { value: textures.dirt.roughnessMap };
335
+ shader.uniforms.snowRough = { value: textures.snow.roughnessMap };
336
+ shader.uniforms.sandRough = { value: textures.sand.roughnessMap };
337
+
338
+ shader.vertexShader = `
339
  varying vec2 vUv;
 
 
340
  varying vec4 vColor;
341
+ ${shader.vertexShader}
342
+ `.replace(
343
+ `#include <uv_vertex>`,
344
+ `#include <uv_vertex>
345
+ vUv = uv;
346
+ vColor = color;`
347
+ );
348
+
349
+ shader.fragmentShader = `
 
 
 
 
350
  varying vec2 vUv;
 
351
  varying vec4 vColor;
352
+
353
+ uniform sampler2D grassMap;
354
+ uniform sampler2D rockMap;
355
+ uniform sampler2D dirtMap;
356
+ uniform sampler2D snowMap;
357
+ uniform sampler2D sandMap;
358
+
359
+ uniform sampler2D grassNormal;
360
+ uniform sampler2D rockNormal;
361
+ uniform sampler2D dirtNormal;
362
+ uniform sampler2D snowNormal;
363
+ uniform sampler2D sandNormal;
364
+
365
+ uniform sampler2D grassRough;
366
+ uniform sampler2D rockRough;
367
+ uniform sampler2D dirtRough;
368
+ uniform sampler2D snowRough;
369
+ uniform sampler2D sandRough;
370
+
371
+ vec4 blend(vec4 texture1, vec4 texture2, float alpha) {
372
+ return mix(texture1, texture2, alpha);
373
  }
 
 
374
 
375
+ ${shader.fragmentShader}
376
+ `.replace(
377
+ `#include <map_fragment>`,
378
+ `
379
+ vec2 uv_scaled = vUv * 50.0;
380
+ vec4 grassTex = texture(grassMap, uv_scaled);
381
+ vec4 rockTex = texture(rockMap, uv_scaled);
382
+ vec4 dirtTex = texture(dirtMap, uv_scaled);
383
+ vec4 snowTex = texture(snowMap, uv_scaled);
384
+ vec4 sandTex = texture(sandMap, uv_scaled);
385
+
386
+ vec4 diffuseColor = grassTex;
387
+ diffuseColor = blend(diffuseColor, rockTex, vColor.r);
388
+ diffuseColor = blend(diffuseColor, dirtTex, vColor.g);
389
+ diffuseColor = blend(diffuseColor, snowTex, vColor.b);
390
+ diffuseColor = blend(diffuseColor, sandTex, vColor.a);
391
+ `
392
+ ).replace(
393
+ `#include <normal_fragment_maps>`,
394
+ `
395
+ vec3 grassN = texture(grassNormal, uv_scaled).xyz * 2.0 - 1.0;
396
+ vec3 rockN = texture(rockNormal, uv_scaled).xyz * 2.0 - 1.0;
397
+ vec3 dirtN = texture(dirtNormal, uv_scaled).xyz * 2.0 - 1.0;
398
+ vec3 snowN = texture(snowNormal, uv_scaled).xyz * 2.0 - 1.0;
399
+ vec3 sandN = texture(sandNormal, uv_scaled).xyz * 2.0 - 1.0;
400
+
401
+ vec3 blendedNormal = grassN;
402
+ blendedNormal = mix(blendedNormal, rockN, vColor.r);
403
+ blendedNormal = mix(blendedNormal, dirtN, vColor.g);
404
+ blendedNormal = mix(blendedNormal, snowN, vColor.b);
405
+ blendedNormal = mix(blendedNormal, sandN, vColor.a);
406
+
407
+ normal = normalize( vTBN * blendedNormal );
408
+ `
409
+ ).replace(
410
+ `#include <roughnessmap_fragment>`,
411
+ `
412
+ float grassR = texture(grassRough, uv_scaled).r;
413
+ float rockR = texture(rockRough, uv_scaled).r;
414
+ float dirtR = texture(dirtRough, uv_scaled).r;
415
+ float snowR = texture(snowRough, uv_scaled).r;
416
+ float sandR = texture(sandRough, uv_scaled).r;
417
+
418
+ float blendedRoughness = grassR;
419
+ blendedRoughness = mix(blendedRoughness, rockR, vColor.r);
420
+ blendedRoughness = mix(blendedRoughness, dirtR, vColor.g);
421
+ blendedRoughness = mix(blendedRoughness, snowR, vColor.b);
422
+ blendedRoughness = mix(blendedRoughness, sandR, vColor.a);
423
+
424
+ roughnessFactor *= blendedRoughness;
425
+ `
426
+ );
427
+ };
428
+
429
  function init() {
430
  scene = new THREE.Scene();
431
+ camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
 
 
 
432
  camera.position.set(50, 50, 50);
433
 
434
  renderer = new THREE.WebGLRenderer({ antialias: true });
435
  renderer.setSize(window.innerWidth, window.innerHeight);
436
  renderer.setPixelRatio(window.devicePixelRatio);
437
  renderer.shadowMap.enabled = true;
438
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
439
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
440
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
441
  document.body.appendChild(renderer.domElement);
442
 
443
+ editorControls = new OrbitControls(camera, renderer.domElement);
444
+ editorControls.enableDamping = true;
445
+ editorControls.maxPolarAngle = Math.PI / 2.1;
446
 
447
+ playControls = new PointerLockControls(camera, document.body);
448
+ playControls.addEventListener('lock', () => {
449
+ document.getElementById('play-mode-ui').style.display = 'none';
450
+ document.getElementById('play-instructions').style.display = 'block';
451
+ });
452
+ playControls.addEventListener('unlock', () => {
453
+ document.getElementById('play-mode-ui').style.display = 'block';
454
+ document.getElementById('play-instructions').style.display = 'none';
455
+ if(isPlayMode) togglePlayMode();
456
+ });
457
+
458
+ const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.5);
459
  scene.add(hemiLight);
460
 
461
  const dirLight = new THREE.DirectionalLight(0xffffff, 2.5);
462
  dirLight.position.set(100, 100, 50);
463
  dirLight.castShadow = true;
464
+ dirLight.shadow.mapSize.width = 4096;
465
+ dirLight.shadow.mapSize.height = 4096;
466
+ dirLight.shadow.camera.top = 150;
467
+ dirLight.shadow.camera.bottom = -150;
468
+ dirLight.shadow.camera.left = -150;
469
+ dirLight.shadow.camera.right = 150;
470
+ dirLight.shadow.bias = -0.001;
471
  scene.add(dirLight);
472
 
473
+ sky = new Sky();
474
+ sky.scale.setScalar(1000);
475
+ scene.add(sky);
476
+
477
+ sun = new THREE.Vector3();
478
+ const uniforms = sky.material.uniforms;
479
+ uniforms['turbidity'].value = 10;
480
+ uniforms['rayleigh'].value = 2;
481
+ uniforms['mieCoefficient'].value = 0.005;
482
+ uniforms['mieDirectionalG'].value = 0.8;
483
+ const phi = THREE.MathUtils.degToRad(90 - 25);
484
+ const theta = THREE.MathUtils.degToRad(180);
485
+ sun.setFromSphericalCoords(1, phi, theta);
486
+ uniforms['sunPosition'].value.copy(sun);
487
+ dirLight.position.copy(sun).multiplyScalar(400);
488
+
489
+ const brushGeometry = new THREE.CylinderGeometry(1, 1, 200, 32, 1, true);
490
  const brushMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, transparent: true, opacity: 0.5 });
491
  brushHelper = new THREE.Mesh(brushGeometry, brushMaterial);
492
+ brushHelper.position.y = -100;
493
  brushHelper.visible = false;
494
  scene.add(brushHelper);
495
 
496
+ const playerGeometry = new THREE.CapsuleGeometry(0.35, PLAYER_HEIGHT - 0.7, 32);
497
+ const playerMaterial = new THREE.MeshStandardMaterial({ color: 0x00ff00, transparent: true, opacity: 0.5 });
498
+ player = new THREE.Mesh(playerGeometry, playerMaterial);
499
+ player.visible = false;
500
+ scene.add(player);
501
+
502
  initFoliage();
503
 
504
+ composer = new EffectComposer(renderer);
505
+ composer.addPass(new RenderPass(scene, camera));
506
+ const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
507
+ ssaoPass.kernelRadius = 16;
508
+ composer.addPass(ssaoPass);
509
+ const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.5, 0.4, 0.85);
510
+ composer.addPass(bloomPass);
511
+ composer.addPass(new OutputPass());
512
+
513
  window.addEventListener('resize', onWindowResize);
514
  renderer.domElement.addEventListener('pointermove', onPointerMove);
515
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
516
  renderer.domElement.addEventListener('pointerup', onPointerUp);
517
  renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
518
 
519
+ document.addEventListener('keydown', onKeyDown);
520
+ document.addEventListener('keyup', onKeyUp);
521
+
522
  setupUIListeners();
523
  animate();
524
  }
525
 
526
  function initFoliage() {
527
+ if (grassInstances) scene.remove(grassInstances);
528
+ const grassBlade = new THREE.PlaneGeometry(0.4, 2.0);
529
+ grassBlade.translate(0, 1.0, 0);
530
+ const grassMaterial = new THREE.MeshStandardMaterial({ color: 0x339933, side: THREE.DoubleSide, alphaTest: 0.5 });
 
 
 
531
  grassInstances = new THREE.InstancedMesh(grassBlade, grassMaterial, MAX_GRASS_COUNT);
532
  grassInstances.count = 0;
533
+ grassInstances.castShadow = true;
534
  scene.add(grassInstances);
535
  }
536
 
 
540
  const height = parseInt(document.getElementById('terrain-height').value);
541
  createTerrain(width, height);
542
  });
543
+ document.getElementById('play-button').addEventListener('click', togglePlayMode);
544
  document.getElementById('save-project').addEventListener('click', saveProject);
545
  document.getElementById('load-project').addEventListener('click', loadProject);
546
+ document.getElementById('brush-size').addEventListener('input', e => {
547
  document.getElementById('brush-size-value').textContent = e.target.value;
548
  updateBrushHelper();
549
  });
550
+ document.getElementById('brush-strength').addEventListener('input', e => {
551
  document.getElementById('brush-strength-value').textContent = e.target.value;
552
  });
553
+ document.getElementById('project-list').addEventListener('change', e => {
554
  document.getElementById('project-name').value = e.target.value;
555
  });
556
  document.getElementById('burger-menu').addEventListener('click', () => {
 
564
  });
565
  }
566
 
567
+ function togglePlayMode() {
568
+ isPlayMode = !isPlayMode;
569
+ const playButton = document.getElementById('play-button');
570
+ const uiPanel = document.getElementById('ui-container');
571
+
572
+ if (isPlayMode) {
573
+ if(!terrainMesh) {
574
+ alert('Создайте ландшафт перед входом в игровой режим');
575
+ isPlayMode = false;
576
+ return;
577
+ }
578
+ playButton.textContent = "Выйти из игры";
579
+ playButton.classList.remove('play-button');
580
+ editorControls.enabled = false;
581
+ brushHelper.visible = false;
582
+ uiPanel.style.display = 'none';
583
+ playControls.lock();
584
+
585
+ player.position.set(0, 50, 0);
586
+ const startRay = new THREE.Raycaster(player.position, new THREE.Vector3(0, -1, 0));
587
+ const intersect = startRay.intersectObject(terrainMesh);
588
+ if (intersect.length > 0) {
589
+ player.position.copy(intersect[0].point).y += PLAYER_HEIGHT / 2;
590
+ }
591
+ playerVelocity.set(0,0,0);
592
+ player.visible = true;
593
+
594
+ } else {
595
+ playButton.textContent = "Запустить игру";
596
+ playButton.classList.add('play-button');
597
+ editorControls.enabled = true;
598
+ uiPanel.style.display = 'block';
599
+ playControls.unlock();
600
+ player.visible = false;
601
+ }
602
  }
603
+
604
+ function showSpinner(show) { document.getElementById('loading-spinner').style.display = show ? 'block' : 'none'; }
605
 
606
  function createTerrain(width, height, terrainData = null) {
607
  if (terrainMesh) {
 
612
 
613
  terrainDimensions.width = width;
614
  terrainDimensions.height = height;
615
+ terrainDimensions.segmentsX = Math.min(256, Math.round(width));
616
+ terrainDimensions.segmentsY = Math.min(256, Math.round(height));
617
 
618
  const geometry = new THREE.PlaneGeometry(
619
  terrainDimensions.width, terrainDimensions.height,
 
645
  }
646
 
647
  terrainMesh = new THREE.Mesh(geometry, terrainMaterial);
 
648
  terrainMesh.receiveShadow = true;
649
  scene.add(terrainMesh);
650
 
 
655
  camera.aspect = window.innerWidth / window.innerHeight;
656
  camera.updateProjectionMatrix();
657
  renderer.setSize(window.innerWidth, window.innerHeight);
658
+ composer.setSize(window.innerWidth, window.innerHeight);
659
  }
660
 
661
+ function getBrushMode() { return document.querySelector('input[name="brush-mode"]:checked').value; }
 
 
662
 
663
  function onPointerMove(event) {
664
+ if (isPlayMode) return;
665
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
666
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
667
 
 
672
 
673
  if (intersects.length > 0) {
674
  const intersectionPoint = intersects[0].point;
675
+ brushHelper.position.x = intersectionPoint.x;
676
+ brushHelper.position.z = intersectionPoint.z;
677
  brushHelper.visible = true;
678
  updateBrushHelper();
679
+ editorControls.enabled = !isInteracting;
680
 
681
+ if (isInteracting) applyBrush(intersects[0]);
 
 
682
  } else {
683
  brushHelper.visible = false;
684
+ editorControls.enabled = true;
685
  }
686
  }
687
 
 
691
  }
692
 
693
  function onPointerDown(event) {
694
+ if (event.button === 0 && !isPlayMode && mouse.x > -0.9) {
695
  if (window.innerWidth < 800 && !document.getElementById('ui-panel').contains(event.target)) {
696
  document.getElementById('ui-panel').classList.remove('open');
697
  }
698
  isInteracting = true;
699
+ editorControls.enabled = false;
700
  }
701
  }
702
 
703
  function onPointerUp(event) {
704
+ if (event.button === 0 && !isPlayMode) {
705
  isInteracting = false;
706
+ editorControls.enabled = true;
707
  }
708
  }
709
 
710
+ function onKeyDown(event) {
711
+ if (!isPlayMode) return;
712
+ switch (event.code) {
713
+ case 'KeyW': moveState.forward = 1; break;
714
+ case 'KeyS': moveState.forward = -1; break;
715
+ case 'KeyA': moveState.right = -1; break;
716
+ case 'KeyD': moveState.right = 1; break;
717
+ case 'Space': if(onGround) playerVelocity.y = JUMP_FORCE; break;
718
+ }
719
+ }
720
+
721
+ function onKeyUp(event) {
722
+ if (!isPlayMode) return;
723
+ switch (event.code) {
724
+ case 'KeyW': moveState.forward = 0; break;
725
+ case 'KeyS': moveState.forward = 0; break;
726
+ case 'KeyA': moveState.right = 0; break;
727
+ case 'KeyD': moveState.right = 0; break;
728
+ }
729
+ }
730
+
731
  function applyBrush(intersection) {
732
  if (!terrainMesh) return;
733
  const brushMode = getBrushMode();
734
  switch(brushMode) {
735
+ case 'raise': case 'lower': sculptTerrain(intersection.point, brushMode); break;
736
+ case 'roughen': roughenTerrain(intersection.point); break;
737
+ case 'paint': paintTexture(intersection.point); break;
738
+ case 'place': placeObject(intersection); break;
 
 
 
 
 
 
 
 
 
739
  }
740
  }
741
 
 
748
 
749
  for (let i = 0; i < positions.count; i++) {
750
  vertex.fromBufferAttribute(positions, i);
751
+ const distanceSq = vertex.distanceToSquared(center);
752
+ if (distanceSq < brushSize * brushSize) {
753
+ const falloff = 1 - Math.sqrt(distanceSq) / brushSize;
754
+ positions.setY(i, positions.getY(i) + direction * falloff * brushStrength);
 
 
755
  }
756
  }
757
  positions.needsUpdate = true;
 
763
  const brushSize = parseFloat(document.getElementById('brush-size').value);
764
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
765
  const vertex = new THREE.Vector3();
 
766
 
767
  for (let i = 0; i < positions.count; i++) {
768
  vertex.fromBufferAttribute(positions, i);
769
+ const distanceSq = vertex.distanceToSquared(center);
770
+ if (distanceSq < brushSize * brushSize) {
771
+ const falloff = 1 - Math.sqrt(distanceSq) / brushSize;
772
+ const noiseVal = noise2D(vertex.x * 2, vertex.z * 2);
773
+ positions.setY(i, positions.getY(i) + noiseVal * falloff * brushStrength);
 
 
774
  }
775
  }
776
  positions.needsUpdate = true;
 
785
  const textureType = document.querySelector('input[name="texture-type"]:checked').value;
786
 
787
  const targetColor = new THREE.Vector4(0,0,0,0);
788
+ if(textureType === 'rock') targetColor.x = 1;
789
+ else if (textureType === 'dirt') targetColor.y = 1;
790
+ else if (textureType === 'snow') targetColor.z = 1;
791
+ else if (textureType === 'sand') targetColor.w = 1;
792
 
793
  const vertex = new THREE.Vector3();
794
  const currentColor = new THREE.Vector4();
795
 
796
  for (let i = 0; i < positions.count; i++) {
797
  vertex.fromBufferAttribute(positions, i);
798
+ const distanceSq = vertex.distanceToSquared(center);
799
+ if (distanceSq < brushSize * brushSize) {
800
+ const falloff = 1 - Math.sqrt(distanceSq) / brushSize;
801
  currentColor.fromBufferAttribute(colors, i);
802
 
803
  if (textureType === 'grass') {
804
+ currentColor.lerp(new THREE.Vector4(0,0,0,0), falloff * brushStrength);
 
 
 
805
  } else {
806
+ const otherLayersWeight = 1.0 - (currentColor.x + currentColor.y + currentColor.z + currentColor.w);
807
  currentColor.lerp(targetColor, falloff * brushStrength);
808
  }
809
 
810
  let sum = currentColor.x + currentColor.y + currentColor.z + currentColor.w;
811
+ if (sum > 1.0) currentColor.divideScalar(sum);
 
 
812
 
813
  colors.setXYZW(i, currentColor.x, currentColor.y, currentColor.z, currentColor.w);
814
  }
 
818
 
819
  function placeObject(intersection) {
820
  const brushSize = parseFloat(document.getElementById('brush-size').value);
821
+ const density = parseFloat(document.getElementById('brush-strength').value) * 5;
822
  const dummy = new THREE.Object3D();
823
 
824
  for (let i = 0; i < density; i++) {
825
  if (grassInstances.count >= MAX_GRASS_COUNT) break;
826
 
827
+ const angle = Math.random() * Math.PI * 2;
828
+ const radius = Math.random() * brushSize;
829
+ const offset = new THREE.Vector3(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
 
830
 
831
+ dummy.position.copy(intersection.point).add(offset);
 
 
 
 
832
 
833
  const placementRaycaster = new THREE.Raycaster(
834
+ new THREE.Vector3(dummy.position.x, 100, dummy.position.z), new THREE.Vector3(0, -1, 0)
 
835
  );
836
  const placementIntersects = placementRaycaster.intersectObject(terrainMesh);
837
 
 
848
 
849
  async function saveProject() {
850
  const projectName = document.getElementById('project-name').value.trim();
851
+ if (!projectName || !terrainMesh) { alert("Введите имя проекта и создайте ландшафт."); return; }
 
852
  showSpinner(true);
853
 
854
  const grassMatrices = [];
855
+ const matrix = new THREE.Matrix4();
856
  for (let i = 0; i < grassInstances.count; i++) {
 
857
  grassInstances.getMatrixAt(i, matrix);
858
  grassMatrices.push(...matrix.elements);
859
  }
860
 
861
  const projectData = {
862
  name: projectName,
863
+ width: terrainDimensions.width, 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
+ foliage: { grass: grassMatrices }
 
 
867
  };
868
 
869
  try {
 
873
  body: JSON.stringify(projectData)
874
  });
875
  const result = await response.json();
876
+ if (!response.ok) throw new Error(result.error);
877
  alert(`Проект '${projectName}' успешно сохранен!`);
878
 
879
  const projectList = document.getElementById('project-list');
880
+ if (![...projectList.options].some(opt => opt.value === projectName)) {
881
  const newOption = document.createElement('option');
882
  newOption.value = newOption.textContent = projectName;
883
  projectList.appendChild(newOption);
 
891
 
892
  async function loadProject() {
893
  const projectName = document.getElementById('project-list').value;
894
+ if (!projectName) { alert("Выберите проект для загрузки."); return; }
895
  showSpinner(true);
896
 
897
  try {
898
  const response = await fetch(`/api/project/${projectName}`);
899
  const result = await response.json();
900
+ if (!response.ok) throw new Error(result.error);
901
 
902
  createTerrain(result.width, result.height, result);
903
  document.getElementById('project-name').value = projectName;
 
910
  }
911
  }
912
 
913
+ function updatePlayer(deltaTime) {
914
+ const speed = (moveState.forward && moveState.right) ? PLAYER_SPEED * 0.707 : PLAYER_SPEED;
915
+
916
+ playerVelocity.y += GRAVITY * deltaTime;
917
+
918
+ playerDirection.set(moveState.right, 0, -moveState.forward);
919
+ playerDirection.applyQuaternion(camera.quaternion).normalize();
920
+
921
+ const move = playerDirection.multiplyScalar(speed * deltaTime);
922
+ player.position.add(move);
923
+
924
+ player.position.y += playerVelocity.y * deltaTime;
925
+
926
+ raycaster.set(player.position, new THREE.Vector3(0, -1, 0));
927
+ if(terrainMesh){
928
+ const intersects = raycaster.intersectObject(terrainMesh);
929
+ if (intersects.length > 0) {
930
+ const distance = intersects[0].distance;
931
+ if (distance < PLAYER_HEIGHT / 2) {
932
+ player.position.y += (PLAYER_HEIGHT / 2 - distance);
933
+ playerVelocity.y = 0;
934
+ onGround = true;
935
+ } else {
936
+ onGround = false;
937
+ }
938
+ } else {
939
+ onGround = false;
940
+ }
941
+ }
942
+ camera.position.copy(player.position);
943
+ }
944
+
945
  function animate() {
946
  requestAnimationFrame(animate);
947
+ const deltaTime = Math.min(0.05, clock.getDelta());
948
+
949
+ if (isPlayMode) {
950
+ updatePlayer(deltaTime);
951
+ } else {
952
+ editorControls.update();
953
+ }
954
+
955
+ composer.render();
956
  }
957
 
958
+ loadTextures();
959
  init();
960
+ createTerrain(256, 256);
961
  </script>
962
  </body>
963
  </html>