Kgshop commited on
Commit
4b49a24
·
verified ·
1 Parent(s): 22d039b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +364 -1123
app.py CHANGED
@@ -8,69 +8,57 @@ from flask import Flask, render_template_string, request, jsonify
8
  from huggingface_hub import HfApi, hf_hub_download
9
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from dotenv import load_dotenv
11
- import requests
12
 
13
  load_dotenv()
14
 
15
  app = Flask(__name__)
16
- app.secret_key = 'level_designer_secret_key_12345'
17
 
18
  REPO_ID = "Kgshop/Testai"
19
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
20
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
21
 
22
- DOWNLOAD_RETRIES = 3
23
- DOWNLOAD_DELAY = 5
24
-
25
  def upload_project_to_hf(local_path, project_name):
26
  if not HF_TOKEN_WRITE:
27
- print("HF_TOKEN_WRITE is not set. Cannot upload.")
28
  return False
29
  try:
30
  api = HfApi()
31
  api.upload_file(
32
  path_or_fileobj=local_path,
33
- path_in_repo=f"projects/{project_name}.json",
34
  repo_id=REPO_ID,
35
  repo_type="dataset",
36
  token=HF_TOKEN_WRITE,
37
- commit_message=f"Save project {project_name} at {datetime.now()}"
38
  )
39
  return True
40
  except Exception as e:
41
  print(f"Error uploading {project_name} to HF: {e}")
42
  return False
43
 
44
- def download_project_from_hf(project_name, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
45
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
46
  if not token_to_use:
47
- print("No Hugging Face token found for reading.")
48
  return None
49
-
50
- for attempt in range(retries + 1):
51
- try:
52
- local_path = hf_hub_download(
53
- repo_id=REPO_ID,
54
- filename=f"projects/{project_name}.json",
55
- repo_type="dataset",
56
- token=token_to_use,
57
- local_dir=".",
58
- local_dir_use_symlinks=False,
59
- force_download=True
60
- )
61
- with open(local_path, 'r', encoding='utf-8') as f:
62
- data = json.load(f)
63
- if os.path.exists(local_path):
64
- os.remove(local_path)
65
- return data
66
- except (HfHubHTTPError, RepositoryNotFoundError) as e:
67
- print(f"Project '{project_name}' not found on HF Hub on attempt {attempt + 1}: {e}")
68
- return None
69
- except Exception as e:
70
- print(f"An unexpected error occurred during download attempt {attempt + 1}: {e}")
71
- if attempt < retries:
72
- time.sleep(delay)
73
- return None
74
 
75
  def list_projects_from_hf():
76
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
@@ -79,14 +67,9 @@ def list_projects_from_hf():
79
  try:
80
  api = HfApi()
81
  repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset", token=token_to_use)
82
-
83
- project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('projects/') and f.endswith('.json')]
84
  return sorted(project_files)
85
- except RepositoryNotFoundError:
86
- print("Repository not found, creating 'projects' directory might be needed on first save.")
87
- return []
88
- except Exception as e:
89
- print(f"Error listing projects from HF: {e}")
90
  return []
91
 
92
  EDITOR_TEMPLATE = '''
@@ -95,16 +78,17 @@ EDITOR_TEMPLATE = '''
95
  <head>
96
  <meta charset="UTF-8">
97
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
98
- <title>Isometric 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;
106
  border-right: 1px solid #444;
107
- width: 300px;
108
  height: 100%;
109
  overflow-y: auto;
110
  box-sizing: border-box;
@@ -112,75 +96,46 @@ EDITOR_TEMPLATE = '''
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[type="number"], input[type="text"], select, input[type="file"] {
120
  width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; margin-bottom: 5px;
121
  }
122
  button {
123
- width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px;
124
  }
125
  button:hover { background: #0099ff; }
126
  button.play-button { background: #22aa22; }
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%);
135
  border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
136
  width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 100;
137
  }
138
- #blocker {
139
- position: absolute; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none;
140
- }
141
- #instructions {
142
- width: 100%; height: 100%;
143
- display: flex; flex-direction: column; justify-content: center; align-items: center;
144
- text-align: center; font-size: 14px; cursor: pointer; color: white;
145
- }
146
  #burger-menu {
147
- position: absolute; top: 15px; left: 15px; z-index: 20;
148
- display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
149
  }
150
- #burger-menu span {
151
- display: block; position: absolute; height: 4px; width: 100%;
152
- background: white; border-radius: 2px; opacity: 1; left: 0;
153
- transition: .25s ease-in-out;
154
- }
155
- #burger-menu span:nth-child(1) { top: 0px; }
156
- #burger-menu span:nth-child(2) { top: 9px; }
157
- #burger-menu span:nth-child(3) { top: 18px; }
158
-
159
  #joystick-container {
160
- position: absolute;
161
- bottom: 30px;
162
- left: 30px;
163
- width: 120px;
164
- height: 120px;
165
- background: rgba(128, 128, 128, 0.3);
166
- border-radius: 50%;
167
- display: none;
168
- z-index: 100;
169
- pointer-events: auto;
170
- user-select: none;
171
- }
172
- #joystick-handle {
173
- position: absolute;
174
- top: 30px;
175
- left: 30px;
176
- width: 60px;
177
- height: 60px;
178
- background: rgba(255, 255, 255, 0.5);
179
- border-radius: 50%;
180
  }
181
-
182
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
183
-
184
  @media (max-width: 800px) {
185
  #ui-panel { transform: translateX(-100%); padding-top: 60px; }
186
  #ui-panel.open { transform: translateX(0); }
@@ -192,1111 +147,431 @@ EDITOR_TEMPLATE = '''
192
  </head>
193
  <body>
194
  <div id="ui-container">
195
- <div id="burger-menu">
196
- <span></span><span></span><span></span>
197
- </div>
198
  <div id="ui-panel">
199
- <div class="ui-group">
200
- <h3>Режим</h3>
201
- <button id="play-mode-toggle" class="play-button" title="Переключиться в режим игры">Играть</button>
202
- </div>
203
  <div class="ui-group">
204
  <h3>Проект</h3>
205
- <label for="project-list" title="Выбрать существующий проект для загрузки">Загрузить проект:</label>
206
  <select id="project-list">
207
  <option value="">Выберите проект...</option>
208
  {% for project in projects %}
209
  <option value="{{ project }}">{{ project }}</option>
210
  {% endfor %}
211
  </select>
212
- <button id="load-project" title="Загрузить выбранный проект">Загрузить</button>
213
  <hr style="border-color: #333; margin: 15px 0;">
214
- <label for="project-name" title="Введите имя для нового или существующего проекта">Имя проекта:</label>
215
  <input type="text" id="project-name" placeholder="new-level-01">
216
- <button id="save-project" title="Сохранить текущий проект на сервере">Сохранить</button>
217
- </div>
218
- <div class="ui-group">
219
- <h3>Кисть</h3>
220
- <div class="radio-group">
221
- <label title="Поднять ландшафт"><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
222
- <label title="Опустить ландшафт"><input type="radio" name="brush-mode" value="lower"> Опустить</label>
223
- <label title="Добавить шум"><input type="radio" name="brush-mode" value="roughen"> Шум</label>
224
- <label title="Сгладить ландшафт"><input type="radio" name="brush-mode" value="smooth"> Сгладить</label>
225
- <label title="Выровнять до определенной высоты"><input type="radio" name="brush-mode" value="flatten"> Выровнять</label>
226
- <label title="Рисовать текстуру"><input type="radio" name="brush-mode" value="paint"> Текстура</label>
227
- <label title="Разместить объект"><input type="radio" name="brush-mode" value="place"> Объект</label>
228
- </div>
229
- <div class="slider-container">
230
- <label for="brush-size" title="Радиус кисти">Размер: <span id="brush-size-value">10</span></label>
231
- <input type="range" id="brush-size" min="1" max="50" value="10">
232
- </div>
233
- <div class="slider-container">
234
- <label for="brush-strength" title="Интенсивность кисти">Сила: <span id="brush-strength-value">0.5</span></label>
235
- <input type="range" id="brush-strength" min="0.1" max="2" step="0.1" value="0.5">
236
- </div>
237
- </div>
238
- <div class="ui-group" id="texture-management-group">
239
- <h3>Управление текстурами</h3>
240
- <label for="texture-slot-select">Слот для изменения:</label>
241
- <select id="texture-slot-select">
242
- <option value="grass">Трава</option>
243
- <option value="rock">Скалы</option>
244
- <option value="dirt">Грунт</option>
245
- <option value="snow">Снег</option>
246
- <option value="sand">Песок</option>
247
- </select>
248
- <label for="texture-slot-name">Новое имя для слота:</label>
249
- <input type="text" id="texture-slot-name" placeholder="например, Лава">
250
- <button id="rename-texture-slot-btn">Переименовать</button>
251
- <hr style="border-color: #333; margin: 15px 0;">
252
- <label for="custom-texture-file">Текстура (Diffuse):</label>
253
- <input type="file" id="custom-texture-file" accept="image/*" title="Выберите изображение для основной текстуры">
254
- <label for="custom-normal-file">Карта нормалей (Normal):</label>
255
- <input type="file" id="custom-normal-file" accept="image/*" title="Выберите изображение для карты нормалей">
256
- <button id="update-texture-btn">Обновить текстуру</button>
257
  </div>
258
  <div class="ui-group">
259
- <h3>Текстуры ландшафта</h3>
260
- <div class="radio-group" id="texture-selector">
261
- <label><input type="radio" name="texture-type" value="grass" checked> Трава</label>
262
- <label><input type="radio" name="texture-type" value="rock"> Скалы</label>
263
- <label><input type="radio" name="texture-type" value="dirt"> Грунт</label>
264
- <label><input type="radio" name="texture-type" value="snow"> Снег</label>
265
- <label><input type="radio" name="texture-type" value="sand"> Песок</label>
266
- </div>
267
  </div>
268
  <div class="ui-group">
269
- <h3>Объекты</h3>
270
- <div class="radio-group" id="object-selector">
271
- <label><input type="radio" name="object-type" value="grass" checked> Трава</label>
272
- <label><input type="radio" name="object-type" value="tree"> Дерево</label>
273
- <label><input type="radio" name="object-type" value="rock"> Камень</label>
274
- </div>
275
- <button id="clear-objects" title="Удалить все объекты с ландшафта">Очистить объекты</button>
 
 
276
  </div>
277
  </div>
278
  </div>
279
-
280
- <div id="blocker">
281
- <div id="instructions">
282
- <p style="font-size:36px">Нажмите, чтобы играть</p>
283
- <p>
284
- Движение: WASD / Джойстик<br/>
285
- </p>
286
- <p>Нажмите ESC для выхода</p>
287
- </div>
288
- </div>
289
-
290
  <div id="loading-spinner"></div>
291
- <div id="joystick-container">
292
- <div id="joystick-handle"></div>
293
- </div>
294
-
295
 
296
  <script type="importmap">
297
- {
298
- "imports": {
299
- "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
300
- "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
301
- "simplex-noise": "https://unpkg.com/simplex-noise@4.0.1/dist/esm/simplex-noise.js"
302
- }
303
- }
304
  </script>
305
-
306
  <script type="module">
307
  import * as THREE from 'three';
308
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
309
- import { Sky } from 'three/addons/objects/Sky.js';
310
- import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
311
- import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
312
- import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
313
- import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
314
- import { createNoise2D } from 'simplex-noise';
315
 
316
- let scene, camera, renderer, orbitControls, composer, brushHelper, sky, sun;
317
- let raycaster = new THREE.Raycaster();
318
- let mouse = new THREE.Vector2();
319
- let isEditing = false;
320
- const noise2D = createNoise2D();
321
-
322
- const chunks = {};
323
- let terrainMeshes = [];
324
- const chunkSize = 64;
325
- const chunkSegments = 32;
326
- const viewDistance = 3;
327
- let loadedTerrainData = {};
328
-
329
- let grassInstances, treeInstances, rockInstances;
330
- let grassMaterial, treeMaterial, rockMaterial;
331
- const MAX_INSTANCE_COUNT = 50000;
332
 
333
  let isPlayMode = false;
334
  let player, playerVelocity = new THREE.Vector3();
335
- const playerSpeed = 15.0;
336
  const keyStates = {};
337
  const clock = new THREE.Clock();
 
338
 
339
- const textureLoader = new THREE.TextureLoader();
340
- let customTextures = {};
341
- let customNormalMaps = {};
342
- let flattenHeight = null;
343
-
344
- const cameraOffset = new THREE.Vector3(80, 80, 80);
345
 
346
- const joystick = {
347
- active: false,
348
- center: new THREE.Vector2(),
349
- current: new THREE.Vector2(),
350
- vector: new THREE.Vector2()
351
- };
352
 
353
- const loadTexture = (url) => {
354
- const tex = textureLoader.load(url);
355
- tex.wrapS = THREE.RepeatWrapping;
356
- tex.wrapT = THREE.RepeatWrapping;
357
- tex.anisotropy = 16;
358
- return tex;
359
- };
360
-
361
- const textures = {
362
- grass: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/grasslight-big.jpg'),
363
- rock: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/rock.jpg'),
364
- dirt: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg'),
365
- snow: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/snow.jpg'),
366
- sand: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg'),
367
- grassNormal: loadTexture('https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/terrain/grasslight-big-nm.jpg'),
368
- rockNormal: loadTexture('https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/terrain/rock-nm.jpg'),
369
- dirtNormal: new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1),
370
- snowNormal: new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1),
371
- sandNormal: new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1)
372
- };
373
-
374
- Object.values(textures).forEach(tex => {
375
- if (tex.isDataTexture) {
376
- tex.needsUpdate = true;
377
- } else {
378
- tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
379
- }
380
- });
381
-
382
- const terrainMaterial = new THREE.ShaderMaterial({
383
- uniforms: {
384
- grassTexture: { value: textures.grass },
385
- rockTexture: { value: textures.rock },
386
- dirtTexture: { value: textures.dirt },
387
- snowTexture: { value: textures.snow },
388
- sandTexture: { value: textures.sand },
389
- grassNormalMap: { value: textures.grassNormal },
390
- rockNormalMap: { value: textures.rockNormal },
391
- dirtNormalMap: { value: textures.dirtNormal },
392
- snowNormalMap: { value: textures.snowNormal },
393
- sandNormalMap: { value: textures.sandNormal },
394
- lightDirection: { value: new THREE.Vector3(0.5, 0.5, 0.5).normalize() }
395
  },
396
- vertexShader: `
397
- varying vec2 vUv;
398
- varying vec3 vNormal;
399
- varying vec3 vViewPosition;
400
- attribute vec4 color;
401
- varying vec4 vColor;
402
- attribute vec4 tangent;
403
- varying vec3 vTangent;
404
- varying vec3 vBitangent;
405
-
406
- void main() {
407
- vUv = uv;
408
- vColor = color;
409
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
410
- vViewPosition = -mvPosition.xyz;
411
- vNormal = normalize(normalMatrix * normal);
412
- vTangent = normalize(normalMatrix * tangent.xyz);
413
- vBitangent = normalize(cross(vNormal, vTangent) * tangent.w);
414
- gl_Position = projectionMatrix * mvPosition;
415
- }
416
- `,
417
- fragmentShader: `
418
- uniform sampler2D grassTexture;
419
- uniform sampler2D rockTexture;
420
- uniform sampler2D dirtTexture;
421
- uniform sampler2D snowTexture;
422
- uniform sampler2D sandTexture;
423
- uniform sampler2D grassNormalMap;
424
- uniform sampler2D rockNormalMap;
425
- uniform sampler2D dirtNormalMap;
426
- uniform sampler2D snowNormalMap;
427
- uniform sampler2D sandNormalMap;
428
-
429
- uniform vec3 lightDirection;
430
-
431
- varying vec2 vUv;
432
- varying vec4 vColor;
433
- varying vec3 vNormal;
434
- varying vec3 vViewPosition;
435
- varying vec3 vTangent;
436
- varying vec3 vBitangent;
437
-
438
- void main() {
439
- vec2 uv_scaled = vUv * 60.0;
440
-
441
- vec4 grass = texture2D(grassTexture, uv_scaled);
442
- vec4 rock = texture2D(rockTexture, uv_scaled);
443
- vec4 dirt = texture2D(dirtTexture, uv_scaled);
444
- vec4 snow = texture2D(snowTexture, uv_scaled);
445
- vec4 sand = texture2D(sandTexture, uv_scaled);
446
-
447
- vec3 finalColor = grass.rgb;
448
- finalColor = mix(finalColor, rock.rgb, vColor.r);
449
- finalColor = mix(finalColor, dirt.rgb, vColor.g);
450
- finalColor = mix(finalColor, snow.rgb, vColor.b);
451
- finalColor = mix(finalColor, sand.rgb, vColor.a);
452
-
453
- mat3 tbn = mat3(vTangent, vBitangent, vNormal);
454
-
455
- vec3 grassNormal = texture2D(grassNormalMap, uv_scaled).xyz * 2.0 - 1.0;
456
- vec3 rockNormal = texture2D(rockNormalMap, uv_scaled).xyz * 2.0 - 1.0;
457
- vec3 dirtNormal = texture2D(dirtNormalMap, uv_scaled).xyz * 2.0 - 1.0;
458
- vec3 snowNormal = texture2D(snowNormalMap, uv_scaled).xyz * 2.0 - 1.0;
459
- vec3 sandNormal = texture2D(sandNormalMap, uv_scaled).xyz * 2.0 - 1.0;
460
-
461
- vec3 finalNormal = grassNormal;
462
- finalNormal = mix(finalNormal, rockNormal, vColor.r);
463
- finalNormal = mix(finalNormal, dirtNormal, vColor.g);
464
- finalNormal = mix(finalNormal, snowNormal, vColor.b);
465
- finalNormal = mix(finalNormal, sandNormal, vColor.a);
466
-
467
- vec3 normal = normalize(tbn * finalNormal);
468
 
469
- float lighting = dot(normal, lightDirection) * 0.5 + 0.5;
470
- gl_FragColor = vec4(finalColor * lighting, 1.0);
471
- }
472
- `
473
- });
474
 
475
  function init() {
476
  scene = new THREE.Scene();
 
477
 
478
- const aspect = window.innerWidth / window.innerHeight;
479
- const frustumSize = 150;
480
- camera = new THREE.OrthographicCamera(frustumSize * aspect / -2, frustumSize * aspect / 2, frustumSize / 2, frustumSize / -2, 1, 2000);
481
- camera.position.set(cameraOffset.x, cameraOffset.y, cameraOffset.z);
482
- camera.lookAt(0,0,0);
483
 
484
  renderer = new THREE.WebGLRenderer({ antialias: true });
485
  renderer.setSize(window.innerWidth, window.innerHeight);
486
  renderer.setPixelRatio(window.devicePixelRatio);
487
  renderer.shadowMap.enabled = true;
488
- renderer.shadowMap.type = THREE.PCFSoftShadowMap;
489
- renderer.toneMapping = THREE.ACESFilmicToneMapping;
490
- renderer.outputColorSpace = THREE.SRGBColorSpace;
491
  document.body.appendChild(renderer.domElement);
492
 
493
  orbitControls = new OrbitControls(camera, renderer.domElement);
494
  orbitControls.enableDamping = true;
495
- orbitControls.enableRotate = false;
496
- orbitControls.zoomSpeed = 2;
 
497
 
498
- const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.5);
499
  scene.add(hemiLight);
500
 
501
  const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
502
- dirLight.position.set(100, 100, 50);
503
  dirLight.castShadow = true;
504
- dirLight.shadow.mapSize.width = 4096;
505
- dirLight.shadow.mapSize.height = 4096;
506
- dirLight.shadow.camera.top = 250;
507
- dirLight.shadow.camera.bottom = -250;
508
- dirLight.shadow.camera.left = -250;
509
- dirLight.shadow.camera.right = 250;
510
- dirLight.shadow.bias = -0.001;
511
- dirLight.shadow.normalBias = 0.05;
512
  scene.add(dirLight);
513
- terrainMaterial.uniforms.lightDirection.value = dirLight.position.clone().normalize();
514
 
515
- sky = new Sky();
516
- sky.scale.setScalar(450000);
517
- scene.add(sky);
518
- sun = new THREE.Vector3();
519
- const effectController = {
520
- turbidity: 10,
521
- rayleigh: 3,
522
- mieCoefficient: 0.005,
523
- mieDirectionalG: 0.7,
524
- elevation: 4,
525
- azimuth: 180,
526
- };
527
- const uniforms = sky.material.uniforms;
528
- uniforms[ 'turbidity' ].value = effectController.turbidity;
529
- uniforms[ 'rayleigh' ].value = effectController.rayleigh;
530
- uniforms[ 'mieCoefficient' ].value = effectController.mieCoefficient;
531
- uniforms[ 'mieDirectionalG' ].value = effectController.mieDirectionalG;
532
- const phi = THREE.MathUtils.degToRad( 90 - effectController.elevation );
533
- const theta = THREE.MathUtils.degToRad( effectController.azimuth );
534
- sun.setFromSphericalCoords( 1, phi, theta );
535
- uniforms[ 'sunPosition' ].value.copy( sun );
536
- dirLight.position.copy(sun).multiplyScalar(100);
537
 
538
- const brushGeometry = new THREE.CylinderGeometry(1, 1, 100, 32, 1, true);
539
- const brushMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, transparent: true, opacity: 0.5 });
540
- brushHelper = new THREE.Mesh(brushGeometry, brushMaterial);
541
- brushHelper.visible = false;
542
- scene.add(brushHelper);
543
 
544
- initFoliage();
545
- initPostprocessing();
 
 
546
  initPlayer();
547
  initJoystick();
548
-
 
549
  window.addEventListener('resize', onWindowResize);
550
  renderer.domElement.addEventListener('pointermove', onPointerMove);
551
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
552
- renderer.domElement.addEventListener('pointerup', onPointerUp);
553
- renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; isEditing = false; });
554
-
555
- setupUIListeners();
556
- animate();
557
- }
558
-
559
- function initPostprocessing() {
560
- composer = new EffectComposer(renderer);
561
- const renderPass = new RenderPass(scene, camera);
562
- composer.addPass(renderPass);
563
-
564
- const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
565
- ssaoPass.kernelRadius = 16;
566
- ssaoPass.minDistance = 0.005;
567
- ssaoPass.maxDistance = 0.1;
568
- composer.addPass(ssaoPass);
569
 
570
- const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.5, 0.4, 0.85);
571
- composer.addPass(bloomPass);
572
  }
573
 
574
- function initFoliage() {
575
- if (grassInstances) scene.remove(grassInstances);
576
- if (treeInstances) scene.remove(treeInstances);
577
- if (rockInstances) scene.remove(rockInstances);
578
-
579
- const grassTexture = textureLoader.load('https://threejs.org/examples/textures/sprites/grass.png');
580
- grassMaterial = new THREE.MeshBasicMaterial({ map: grassTexture, side: THREE.DoubleSide, transparent: true, alphaTest: 0.5 });
581
- const grassBlade = new THREE.PlaneGeometry(0.8, 1.8);
582
- grassBlade.translate(0, 0.9, 0);
583
- grassInstances = new THREE.InstancedMesh(grassBlade, grassMaterial, MAX_INSTANCE_COUNT);
584
- grassInstances.castShadow = true;
585
- grassInstances.count = 0;
586
- scene.add(grassInstances);
587
-
588
- treeMaterial = new THREE.MeshLambertMaterial({ color: 0x228B22 });
589
- const treeTrunk = new THREE.CylinderGeometry(0.5, 0.7, 5);
590
- const treeCrown = new THREE.ConeGeometry(2.5, 8, 8);
591
- treeCrown.translate(0, 6, 0);
592
- const treeGeometry = new THREE.BufferGeometry();
593
-
594
- const treeGeometries = [treeTrunk, treeCrown];
595
- let totalVertices = 0;
596
- let totalIndices = 0;
597
- treeGeometries.forEach(geom => {
598
- totalVertices += geom.attributes.position.count;
599
- totalIndices += geom.index.count;
600
- });
601
-
602
- const positions = new Float32Array(totalVertices * 3);
603
- const normals = new Float32Array(totalVertices * 3);
604
- const indices = new (totalIndices > 65535 ? Uint32Array : Uint16Array)(totalIndices);
605
-
606
- let vertexOffset = 0;
607
- let indexOffset = 0;
608
- treeGeometries.forEach(geom => {
609
- positions.set(geom.attributes.position.array, vertexOffset * 3);
610
- normals.set(geom.attributes.normal.array, vertexOffset * 3);
611
- for (let i = 0; i < geom.index.count; i++) {
612
- indices[indexOffset + i] = geom.index.array[i] + vertexOffset;
613
  }
614
- vertexOffset += geom.attributes.position.count;
615
- indexOffset += geom.index.count;
616
- });
617
- treeGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
618
- treeGeometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
619
- treeGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
620
-
621
- treeInstances = new THREE.InstancedMesh(treeGeometry, treeMaterial, MAX_INSTANCE_COUNT);
622
- treeInstances.castShadow = true;
623
- treeInstances.count = 0;
624
- scene.add(treeInstances);
625
-
626
- rockMaterial = new THREE.MeshLambertMaterial({ color: 0x808080 });
627
- const rockGeometry = new THREE.IcosahedronGeometry(1, 1);
628
- rockInstances = new THREE.InstancedMesh(rockGeometry, rockMaterial, MAX_INSTANCE_COUNT);
629
- rockInstances.castShadow = true;
630
- rockInstances.count = 0;
631
- scene.add(rockInstances);
632
  }
633
 
634
  function initPlayer() {
635
- const playerGeometry = new THREE.CapsuleGeometry(0.5, 1.0);
636
- const playerMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 });
637
- player = new THREE.Mesh(playerGeometry, playerMaterial);
638
- player.position.set(0, 20, 0);
 
639
  player.visible = false;
640
  scene.add(player);
641
-
642
- const blocker = document.getElementById('blocker');
643
- const instructions = document.getElementById('instructions');
644
- instructions.addEventListener('click', () => {
645
- if(isPlayMode) {
646
- blocker.style.display = 'none';
647
- }
648
- });
649
-
650
- document.addEventListener('keydown', (event) => { keyStates[event.code] = true; });
651
- document.addEventListener('keyup', (event) => { keyStates[event.code] = false; });
652
- document.addEventListener('keydown', (event) => {
653
- if (isPlayMode && event.code === 'Escape') {
654
- blocker.style.display = 'block';
655
- }
656
- });
657
  }
658
-
659
- function setupUIListeners() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  document.getElementById('save-project').addEventListener('click', saveProject);
661
  document.getElementById('load-project').addEventListener('click', loadProject);
662
- document.getElementById('brush-size').addEventListener('input', (e) => {
663
- document.getElementById('brush-size-value').textContent = e.target.value;
664
- updateBrushHelper();
665
- });
666
- document.getElementById('brush-strength').addEventListener('input', (e) => {
667
- document.getElementById('brush-strength-value').textContent = e.target.value;
668
- });
669
- document.getElementById('project-list').addEventListener('change', (e) => {
670
- document.getElementById('project-name').value = e.target.value;
671
- });
672
- document.getElementById('burger-menu').addEventListener('click', () => {
673
- document.getElementById('ui-panel').classList.toggle('open');
674
- });
675
- document.getElementById('clear-objects').addEventListener('click', () => {
676
- if (confirm('Вы уверены, что хотите удалить все объекты?')) {
677
- grassInstances.count = 0;
678
- treeInstances.count = 0;
679
- rockInstances.count = 0;
680
- grassInstances.instanceMatrix.needsUpdate = true;
681
- treeInstances.instanceMatrix.needsUpdate = true;
682
- rockInstances.instanceMatrix.needsUpdate = true;
683
- }
684
- });
685
  document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
686
- document.getElementById('update-texture-btn').addEventListener('click', updateCustomTexture);
687
- document.getElementById('rename-texture-slot-btn').addEventListener('click', renameTextureSlot);
688
- document.getElementById('texture-slot-select').addEventListener('change', (e) => {
689
- const slotName = e.target.value;
690
- const textureSelector = document.getElementById('texture-selector');
691
- const label = textureSelector.querySelector(`label input[value="${slotName}"]`).parentElement;
692
- document.getElementById('texture-slot-name').value = label.innerText.trim();
693
- });
694
  }
695
-
696
  function initJoystick() {
697
  const joystickContainer = document.getElementById('joystick-container');
698
  const joystickHandle = document.getElementById('joystick-handle');
699
- const maxRadius = joystickContainer.clientWidth / 2 - joystickHandle.clientWidth / 2;
700
 
701
  function onTouchStart(event) {
702
- event.preventDefault();
703
  const touch = event.touches[0];
704
  joystick.active = true;
705
  joystick.center.set(touch.clientX, touch.clientY);
706
- joystickContainer.style.left = `${touch.clientX - joystickContainer.clientWidth / 2}px`;
707
- joystickContainer.style.top = `${touch.clientY - joystickContainer.clientHeight / 2}px`;
708
  }
709
-
710
  function onTouchMove(event) {
711
  if (!joystick.active) return;
712
- event.preventDefault();
713
  const touch = event.touches[0];
714
  joystick.current.set(touch.clientX, touch.clientY);
715
  joystick.vector.copy(joystick.current).sub(joystick.center);
716
-
717
- if (joystick.vector.length() > maxRadius) {
718
- joystick.vector.setLength(maxRadius);
719
- }
720
-
721
  joystickHandle.style.transform = `translate(${joystick.vector.x}px, ${joystick.vector.y}px)`;
722
  }
723
-
724
- function onTouchEnd(event) {
725
  joystick.active = false;
726
  joystick.vector.set(0, 0);
727
  joystickHandle.style.transform = 'translate(0, 0)';
728
  }
729
- joystickContainer.addEventListener('touchstart', onTouchStart, { passive: false });
730
- window.addEventListener('touchmove', onTouchMove, { passive: false });
731
  window.addEventListener('touchend', onTouchEnd);
732
  }
733
 
734
- function updateCustomTexture() {
735
- const textureFileInput = document.getElementById('custom-texture-file');
736
- const normalFileInput = document.getElementById('custom-normal-file');
737
- const textureSlot = document.getElementById('texture-slot-select').value;
738
-
739
- const textureFile = textureFileInput.files[0];
740
- const normalFile = normalFileInput.files[0];
741
-
742
- if (!textureFile && !normalFile) {
743
- alert('Пожалуйста, выберите файл текстуры или карты нормалей.');
744
- return;
745
- }
746
-
747
- const updateTexture = (file, isNormalMap) => {
748
- if (!file) return;
749
- const reader = new FileReader();
750
- reader.onload = (event) => {
751
- const dataUrl = event.target.result;
752
-
753
- if (isNormalMap) {
754
- customNormalMaps[textureSlot] = dataUrl;
755
- } else {
756
- customTextures[textureSlot] = dataUrl;
757
- }
758
-
759
- const newTexture = loadTexture(dataUrl);
760
- const uniformName = textureSlot + (isNormalMap ? 'NormalMap' : 'Texture');
761
-
762
- if (terrainMaterial.uniforms[uniformName]) {
763
- if (terrainMaterial.uniforms[uniformName].value) {
764
- terrainMaterial.uniforms[uniformName].value.dispose();
765
- }
766
- terrainMaterial.uniforms[uniformName].value = newTexture;
767
- terrainMaterial.needsUpdate = true;
768
- alert(`Карта ${isNormalMap ? 'нормалей' : 'текстуры'} для слота "${textureSlot}" успешно обновлена.`);
769
- }
770
- };
771
- reader.readAsDataURL(file);
772
- };
773
-
774
- updateTexture(textureFile, false);
775
- updateTexture(normalFile, true);
776
- }
777
-
778
- function renameTextureSlot() {
779
- const slotValue = document.getElementById('texture-slot-select').value;
780
- const newName = document.getElementById('texture-slot-name').value.trim();
781
-
782
- if (!newName) {
783
- alert("Имя слота не может быть пустым.");
784
- return;
785
- }
786
-
787
- const textureSelector = document.getElementById('texture-selector');
788
- const label = textureSelector.querySelector(`label input[value="${slotValue}"]`).parentElement;
789
-
790
- label.childNodes[1].nodeValue = " " + newName;
791
-
792
- const option = document.querySelector(`#texture-slot-select option[value="${slotValue}"]`);
793
- option.textContent = newName;
794
-
795
- alert(`Слот "${slotValue}" переименован в "${newName}".`);
796
- }
797
-
798
- function updateTextureUIAfterLoad(projectData) {
799
- if (!projectData.textureNames) return;
800
-
801
- const textureSelector = document.getElementById('texture-selector');
802
- const slotSelect = document.getElementById('texture-slot-select');
803
-
804
- Object.entries(projectData.textureNames).forEach(([slotValue, newName]) => {
805
- const label = textureSelector.querySelector(`label input[value="${slotValue}"]`).parentElement;
806
- if (label) {
807
- label.childNodes[1].nodeValue = " " + newName;
808
- }
809
- const option = slotSelect.querySelector(`option[value="${slotValue}"]`);
810
- if (option) {
811
- option.textContent = newName;
812
- }
813
- });
814
- }
815
-
816
-
817
- function togglePlayMode() {
818
- isPlayMode = !isPlayMode;
819
- const toggleButton = document.getElementById('play-mode-toggle');
820
- const uiPanel = document.getElementById('ui-container');
821
- const blocker = document.getElementById('blocker');
822
- const joystickContainer = document.getElementById('joystick-container');
823
-
824
- if (isPlayMode) {
825
- toggleButton.textContent = "Редактор";
826
- toggleButton.classList.remove('play-button');
827
- uiPanel.style.display = 'none';
828
- brushHelper.visible = false;
829
- orbitControls.enabled = false;
830
- player.visible = true;
831
- joystickContainer.style.display = 'block';
832
-
833
- const terrainMeshesToTest = Object.values(chunks).map(c => c.mesh);
834
- if (terrainMeshesToTest.length > 0) {
835
- const spawnRaycaster = new THREE.Raycaster(new THREE.Vector3(player.position.x, 100, player.position.z), new THREE.Vector3(0, -1, 0));
836
- const intersects = spawnRaycaster.intersectObjects(terrainMeshesToTest);
837
- if (intersects.length > 0) {
838
- player.position.copy(intersects[0].point);
839
- }
840
- }
841
- player.position.y += 1.5;
842
-
843
- blocker.style.display = 'block';
844
-
845
  } else {
846
- toggleButton.textContent = "Играть";
847
- toggleButton.classList.add('play-button');
848
- uiPanel.style.display = 'block';
849
- orbitControls.enabled = true;
850
- player.visible = false;
851
- blocker.style.display = 'none';
852
- joystickContainer.style.display = 'none';
853
-
854
- camera.position.copy(player.position).add(cameraOffset);
855
- orbitControls.target.copy(player.position);
856
- orbitControls.update();
857
- }
858
- }
859
-
860
- function showSpinner(show) {
861
- document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
862
- }
863
-
864
- function getChunkKey(x, z) {
865
- return `${x},${z}`;
866
- }
867
-
868
- function createChunk(chunkX, chunkZ) {
869
- const key = getChunkKey(chunkX, chunkZ);
870
- const geometry = new THREE.PlaneGeometry(chunkSize, chunkSize, chunkSegments, chunkSegments);
871
- geometry.rotateX(-Math.PI / 2);
872
-
873
- const colors = new Float32Array(geometry.attributes.position.count * 4);
874
- geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
875
-
876
- const positions = geometry.attributes.position;
877
- const chunkData = loadedTerrainData[key];
878
-
879
- if (chunkData && chunkData.heights) {
880
- for (let i = 0; i < positions.count; i++) {
881
- positions.setY(i, chunkData.heights[i]);
882
- }
883
- if (chunkData.colors) {
884
- geometry.attributes.color.array.set(chunkData.colors);
885
- }
886
- } else {
887
- for (let i = 0; i < positions.count; i++) {
888
- const worldX = positions.getX(i) + chunkX * chunkSize;
889
- const worldZ = positions.getZ(i) + chunkZ * chunkSize;
890
- const noiseVal = noise2D(worldX * 0.02, worldZ * 0.02) * 10 + noise2D(worldX * 0.1, worldZ * 0.1) * 2;
891
- positions.setY(i, noiseVal);
892
- }
893
- }
894
-
895
- geometry.computeTangents();
896
- geometry.computeVertexNormals();
897
-
898
- const mesh = new THREE.Mesh(geometry, terrainMaterial);
899
- mesh.position.set(chunkX * chunkSize, 0, chunkZ * chunkSize);
900
- mesh.castShadow = true;
901
- mesh.receiveShadow = true;
902
-
903
- scene.add(mesh);
904
-
905
- chunks[key] = { mesh: mesh, isModified: false, x: chunkX, z: chunkZ };
906
- terrainMeshes.push(mesh);
907
- }
908
-
909
- function disposeChunk(key) {
910
- const chunk = chunks[key];
911
- if (chunk) {
912
- scene.remove(chunk.mesh);
913
- chunk.mesh.geometry.dispose();
914
- terrainMeshes = terrainMeshes.filter(m => m !== chunk.mesh);
915
- delete chunks[key];
916
- }
917
- }
918
-
919
- function updateChunks() {
920
- const focusPoint = isPlayMode ? player.position : orbitControls.target;
921
- const currentChunkX = Math.round(focusPoint.x / chunkSize);
922
- const currentChunkZ = Math.round(focusPoint.z / chunkSize);
923
-
924
- const loadedChunks = {};
925
- for (let x = currentChunkX - viewDistance; x <= currentChunkX + viewDistance; x++) {
926
- for (let z = currentChunkZ - viewDistance; z <= currentChunkZ + viewDistance; z++) {
927
- const key = getChunkKey(x, z);
928
- loadedChunks[key] = true;
929
- if (!chunks[key]) {
930
- createChunk(x, z);
931
- }
932
- }
933
- }
934
-
935
- for (const key in chunks) {
936
- if (!loadedChunks[key]) {
937
- disposeChunk(key);
938
- }
939
  }
 
 
 
940
  }
941
 
942
  function onWindowResize() {
943
- const aspect = window.innerWidth / window.innerHeight;
944
- const frustumSize = 150;
945
- camera.left = frustumSize * aspect / -2;
946
- camera.right = frustumSize * aspect / 2;
947
- camera.top = frustumSize / 2;
948
- camera.bottom = frustumSize / -2;
949
  camera.updateProjectionMatrix();
950
  renderer.setSize(window.innerWidth, window.innerHeight);
951
- composer.setSize(window.innerWidth, window.innerHeight);
952
- }
953
-
954
- function getBrushMode() {
955
- return document.querySelector('input[name="brush-mode"]:checked').value;
956
  }
957
 
958
  function onPointerMove(event) {
959
  if (isPlayMode) return;
960
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
961
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
962
-
963
- if (terrainMeshes.length === 0) return;
964
  raycaster.setFromCamera(mouse, camera);
965
- const intersects = raycaster.intersectObjects(terrainMeshes);
966
-
967
  if (intersects.length > 0) {
968
- const intersectionPoint = intersects[0].point;
969
- brushHelper.position.copy(intersectionPoint);
970
- brushHelper.position.y = -50;
971
- brushHelper.visible = true;
972
- updateBrushHelper();
973
-
974
- if (isEditing) {
975
- applyBrush(intersects[0]);
 
 
 
 
976
  }
 
977
  } else {
978
- brushHelper.visible = false;
979
  }
980
  }
981
 
982
- function updateBrushHelper() {
983
- const brushSize = parseFloat(document.getElementById('brush-size').value);
984
- brushHelper.scale.set(brushSize, 1, brushSize);
985
- }
986
-
987
- function onPointerDown(event) {
988
  if (isPlayMode) return;
989
- if (event.button === 0) {
990
- if (window.innerWidth < 800 && !document.getElementById('ui-panel').contains(event.target)) {
991
- document.getElementById('ui-panel').classList.remove('open');
992
- }
993
- isEditing = true;
994
-
995
- if (event.pointerType === 'touch') {
996
- orbitControls.enabled = false;
997
- }
998
-
999
- if (getBrushMode() === 'flatten') {
1000
- raycaster.setFromCamera(mouse, camera);
1001
- if (terrainMeshes.length > 0) {
1002
- const intersects = raycaster.intersectObjects(terrainMeshes);
1003
- if (intersects.length > 0) {
1004
- flattenHeight = intersects[0].point.y;
1005
- }
1006
- }
1007
- }
1008
- }
1009
- }
1010
-
1011
- function onPointerUp(event) {
1012
- if (event.button === 0) {
1013
- isEditing = false;
1014
- flattenHeight = null;
1015
- if (event.pointerType === 'touch') {
1016
- orbitControls.enabled = true;
1017
- }
1018
  }
1019
  }
1020
 
1021
- function applyBrush(intersection) {
1022
- if (terrainMeshes.length === 0) return;
1023
- const brushMode = getBrushMode();
1024
- switch(brushMode) {
1025
- case 'raise': case 'lower': sculptTerrain(intersection.point, brushMode); break;
1026
- case 'roughen': roughenTerrain(intersection.point); break;
1027
- case 'smooth': smoothTerrain(intersection.point); break;
1028
- case 'flatten': flattenTerrain(intersection.point); break;
1029
- case 'paint': paintTexture(intersection.point); break;
1030
- case 'place': placeObject(intersection); break;
1031
  }
1032
- }
1033
-
1034
- function forEachVertexInBrush(center, callback) {
1035
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1036
- const brushSizeSq = brushSize * brushSize;
1037
- const vertex = new THREE.Vector3();
1038
-
1039
- for (const key in chunks) {
1040
- const chunk = chunks[key];
1041
- const chunkPos = chunk.mesh.position;
1042
-
1043
- const distToChunkSq = (center.x - chunkPos.x)**2 + (center.z - chunkPos.z)**2;
1044
- if (distToChunkSq > (chunkSize/2 + brushSize)**2) continue;
1045
-
1046
- const positions = chunk.mesh.geometry.attributes.position;
1047
- let modified = false;
1048
 
1049
- for (let i = 0; i < positions.count; i++) {
1050
- vertex.fromBufferAttribute(positions, i).add(chunkPos);
1051
- const distanceSq = (vertex.x - center.x)**2 + (vertex.z - center.z)**2;
1052
- if (distanceSq < brushSizeSq) {
1053
- if (callback(chunk, i, vertex, Math.sqrt(distanceSq))) {
1054
- modified = true;
1055
- }
1056
- }
1057
- }
1058
- if (modified) {
1059
- positions.needsUpdate = true;
1060
- chunk.mesh.geometry.computeVertexNormals();
1061
- chunk.mesh.geometry.computeTangents();
1062
- chunk.isModified = true;
1063
- }
1064
  }
 
1065
  }
1066
-
1067
-
1068
- function sculptTerrain(center, mode) {
1069
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1070
- const brushStrength = parseFloat(document.getElementById('brush-strength').value);
1071
- const direction = (mode === 'raise') ? 1 : -1;
1072
-
1073
- forEachVertexInBrush(center, (chunk, index, vertex, distance) => {
1074
- const falloff = Math.pow(1 - (distance / brushSize), 2);
1075
- const positions = chunk.mesh.geometry.attributes.position;
1076
- let currentY = positions.getY(index);
1077
- let newY = currentY + direction * falloff * brushStrength;
1078
- positions.setY(index, newY);
1079
- return true;
1080
- });
1081
  }
1082
 
1083
- function roughenTerrain(center) {
1084
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1085
- const brushStrength = parseFloat(document.getElementById('brush-strength').value);
1086
- const noiseFrequency = 1.5;
1087
-
1088
- forEachVertexInBrush(center, (chunk, index, vertex, distance) => {
1089
- const falloff = Math.pow(1 - (distance / brushSize), 2);
1090
- let noiseVal = noise2D(vertex.x * noiseFrequency, vertex.z * noiseFrequency);
1091
- const positions = chunk.mesh.geometry.attributes.position;
1092
- let currentY = positions.getY(index);
1093
- let newY = currentY + noiseVal * falloff * brushStrength;
1094
- positions.setY(index, newY);
1095
- return true;
1096
- });
1097
- }
1098
-
1099
- function smoothTerrain(center) {
1100
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1101
- const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
1102
 
1103
- const verticesInBrush = [];
1104
- let totalHeight = 0;
1105
-
1106
- forEachVertexInBrush(center, (chunk, index, vertex, distance) => {
1107
- verticesInBrush.push({ chunk, index, vertex, distance });
1108
- totalHeight += vertex.y;
1109
- return false;
1110
- });
1111
- if (verticesInBrush.length === 0) return;
1112
 
1113
- const averageHeight = totalHeight / verticesInBrush.length;
1114
-
1115
- verticesInBrush.forEach(v => {
1116
- const falloff = Math.pow(1 - (v.distance / brushSize), 2);
1117
- const positions = v.chunk.mesh.geometry.attributes.position;
1118
- const currentY = positions.getY(v.index);
1119
- const newY = THREE.MathUtils.lerp(currentY, averageHeight, falloff * brushStrength);
1120
- positions.setY(v.index, newY);
1121
- positions.needsUpdate = true;
1122
- v.chunk.isModified = true;
1123
- });
1124
-
1125
- Object.values(chunks).forEach(chunk => {
1126
- if(chunk.isModified) {
1127
- chunk.mesh.geometry.computeVertexNormals();
1128
- chunk.mesh.geometry.computeTangents();
1129
- }
1130
- });
1131
  }
1132
 
1133
- function flattenTerrain(center) {
1134
- if (flattenHeight === null) return;
1135
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1136
- const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
1137
-
1138
- forEachVertexInBrush(center, (chunk, index, vertex, distance) => {
1139
- const falloff = Math.pow(1 - (distance / brushSize), 2);
1140
- const positions = chunk.mesh.geometry.attributes.position;
1141
- const currentY = positions.getY(index);
1142
- const newY = THREE.MathUtils.lerp(currentY, flattenHeight - chunk.mesh.position.y, falloff * brushStrength);
1143
- positions.setY(index, newY);
1144
- return true;
1145
- });
1146
  }
1147
 
1148
- function paintTexture(center) {
1149
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1150
- const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
1151
- const textureType = document.querySelector('input[name="texture-type"]:checked').value;
1152
 
1153
- const targetColor = new THREE.Vector4(0,0,0,0);
1154
- if(textureType === 'rock') targetColor.x = 1;
1155
- else if (textureType === 'dirt') targetColor.y = 1;
1156
- else if (textureType === 'snow') targetColor.z = 1;
1157
- else if (textureType === 'sand') targetColor.w = 1;
1158
-
1159
- const currentColor = new THREE.Vector4();
1160
- const modifiedChunks = new Set();
 
 
 
 
 
 
 
 
 
 
 
 
 
1161
 
1162
- forEachVertexInBrush(center, (chunk, index, vertex, distance) => {
1163
- const colors = chunk.mesh.geometry.attributes.color;
1164
- const falloff = Math.pow(1 - (distance / brushSize), 2);
1165
- currentColor.fromBufferAttribute(colors, index);
1166
-
1167
- if (textureType === 'grass') {
1168
- currentColor.x = Math.max(0, currentColor.x - falloff * brushStrength);
1169
- currentColor.y = Math.max(0, currentColor.y - falloff * brushStrength);
1170
- currentColor.z = Math.max(0, currentColor.z - falloff * brushStrength);
1171
- currentColor.w = Math.max(0, currentColor.w - falloff * brushStrength);
1172
- } else {
1173
- currentColor.lerp(targetColor, falloff * brushStrength);
1174
- }
1175
-
1176
- let sum = currentColor.x + currentColor.y + currentColor.z + currentColor.w;
1177
- if (sum > 1.0) {
1178
- currentColor.divideScalar(sum);
1179
  }
1180
- colors.setXYZW(index, currentColor.x, currentColor.y, currentColor.z, currentColor.w);
1181
- modifiedChunks.add(chunk);
1182
- return false;
1183
- });
1184
-
1185
- modifiedChunks.forEach(chunk => {
1186
- chunk.mesh.geometry.attributes.color.needsUpdate = true;
1187
- chunk.isModified = true;
1188
- });
1189
  }
1190
 
1191
- function placeObject(intersection) {
1192
- const brushSize = parseFloat(document.getElementById('brush-size').value);
1193
- const density = parseFloat(document.getElementById('brush-strength').value) * 2;
1194
- const objectType = document.querySelector('input[name="object-type"]:checked').value;
1195
- const dummy = new THREE.Object3D();
1196
 
1197
- let targetInstances;
1198
- switch(objectType) {
1199
- case 'grass': targetInstances = grassInstances; break;
1200
- case 'tree': targetInstances = treeInstances; break;
1201
- case 'rock': targetInstances = rockInstances; break;
1202
- }
1203
- if (!targetInstances) return;
1204
-
1205
- for (let i = 0; i < density; i++) {
1206
- if (targetInstances.count >= MAX_INSTANCE_COUNT) break;
1207
-
1208
- const randomPoint = new THREE.Vector2(
1209
- (Math.random() - 0.5) * brushSize * 2,
1210
- (Math.random() - 0.5) * brushSize * 2
1211
- );
1212
-
1213
- dummy.position.set(
1214
- intersection.point.x + randomPoint.x,
1215
- intersection.point.y,
1216
- intersection.point.z + randomPoint.y
1217
- );
1218
-
1219
- const placementRaycaster = new THREE.Raycaster(
1220
- new THREE.Vector3(dummy.position.x, 100, dummy.position.z),
1221
- new THREE.Vector3(0, -1, 0)
1222
- );
1223
- if (terrainMeshes.length > 0) {
1224
- const placementIntersects = placementRaycaster.intersectObjects(terrainMeshes);
1225
-
1226
- if (placementIntersects.length > 0) {
1227
- dummy.position.copy(placementIntersects[0].point);
1228
- dummy.rotation.y = Math.random() * Math.PI * 2;
1229
- let scale = Math.random() * 0.5 + 0.75;
1230
- if (objectType === 'rock') scale *= 2.0;
1231
- dummy.scale.set(scale, scale, scale);
1232
- dummy.updateMatrix();
1233
- targetInstances.setMatrixAt(targetInstances.count++, dummy.matrix);
1234
- }
1235
- }
1236
  }
1237
- targetInstances.instanceMatrix.needsUpdate = true;
1238
  }
1239
 
1240
  async function saveProject() {
1241
  const projectName = document.getElementById('project-name').value.trim();
1242
  if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
1243
- if (Object.keys(chunks).length === 0) { alert("Нечего сохранять."); return; }
1244
  showSpinner(true);
1245
-
1246
- const getMatrices = (instances) => {
1247
- const matrices = [];
1248
- for (let i = 0; i < instances.count; i++) {
1249
- const matrix = new THREE.Matrix4();
1250
- instances.getMatrixAt(i, matrix);
1251
- matrices.push(...matrix.elements);
1252
- }
1253
- return matrices;
1254
- }
1255
-
1256
- const textureNames = {};
1257
- document.querySelectorAll('#texture-selector label').forEach(label => {
1258
- const input = label.querySelector('input');
1259
- const slotValue = input.value;
1260
- const name = label.innerText.trim();
1261
- textureNames[slotValue] = name;
1262
- });
1263
-
1264
- const terrainData = {};
1265
- for(const key in chunks) {
1266
- const chunk = chunks[key];
1267
- if (chunk.isModified) {
1268
- terrainData[key] = {
1269
- heights: Array.from(chunk.mesh.geometry.attributes.position.array.filter((_, i) => i % 3 === 1)),
1270
- colors: Array.from(chunk.mesh.geometry.attributes.color.array)
1271
- }
1272
- }
1273
- }
1274
-
1275
- const projectData = {
1276
- name: projectName,
1277
- terrain: terrainData,
1278
- objects: {
1279
- grass: getMatrices(grassInstances),
1280
- tree: getMatrices(treeInstances),
1281
- rock: getMatrices(rockInstances)
1282
- },
1283
- customTextures: customTextures,
1284
- customNormalMaps: customNormalMaps,
1285
- textureNames: textureNames
1286
- };
1287
-
1288
  try {
1289
  const response = await fetch('/api/project', {
1290
  method: 'POST',
1291
  headers: { 'Content-Type': 'application/json' },
1292
- body: JSON.stringify(projectData)
1293
  });
1294
- const result = await response.json();
1295
- if (!response.ok) throw new Error(result.error || 'Ошибка при сохранении проекта.');
1296
  alert(`Проект '${projectName}' успешно сохранен!`);
1297
-
1298
  const projectList = document.getElementById('project-list');
1299
- if (!Array.from(projectList.options).some(opt => opt.value === projectName)) {
1300
  const newOption = document.createElement('option');
1301
  newOption.value = newOption.textContent = projectName;
1302
  projectList.appendChild(newOption);
@@ -1307,68 +582,19 @@ EDITOR_TEMPLATE = '''
1307
  showSpinner(false);
1308
  }
1309
  }
1310
-
1311
  async function loadProject() {
1312
  const projectName = document.getElementById('project-list').value;
1313
  if (!projectName) { alert("Пожалуйста, выберите проект для загрузки."); return; }
1314
  showSpinner(true);
1315
-
1316
  try {
1317
  const response = await fetch(`/api/project/${projectName}`);
1318
  const result = await response.json();
1319
- if (!response.ok) throw new Error(result.error || шибка при загрузке проекта.');
1320
-
1321
- for (const key in chunks) { disposeChunk(key); }
1322
- chunks = {};
1323
- terrainMeshes = [];
1324
- initFoliage();
1325
-
1326
- loadedTerrainData = result.terrain || {};
1327
 
1328
- const loadInstances = (data, instances) => {
1329
- if (data && data.length > 0) {
1330
- instances.count = data.length / 16;
1331
- for (let i = 0; i < instances.count; i++) {
1332
- const matrix = new THREE.Matrix4();
1333
- matrix.fromArray(data, i * 16);
1334
- instances.setMatrixAt(i, matrix);
1335
- }
1336
- instances.instanceMatrix.needsUpdate = true;
1337
- }
1338
- };
1339
-
1340
- if (result.objects) {
1341
- loadInstances(result.objects.grass, grassInstances);
1342
- loadInstances(result.objects.tree, treeInstances);
1343
- loadInstances(result.objects.rock, rockInstances);
1344
- }
1345
-
1346
- if (result.customTextures) {
1347
- customTextures = result.customTextures;
1348
- Object.entries(customTextures).forEach(([slot, dataUrl]) => {
1349
- const newTexture = loadTexture(dataUrl);
1350
- const uniformName = slot + 'Texture';
1351
- if (terrainMaterial.uniforms[uniformName]) {
1352
- terrainMaterial.uniforms[uniformName].value.dispose();
1353
- terrainMaterial.uniforms[uniformName].value = newTexture;
1354
- }
1355
- });
1356
- }
1357
- if (result.customNormalMaps) {
1358
- customNormalMaps = result.customNormalMaps;
1359
- Object.entries(customNormalMaps).forEach(([slot, dataUrl]) => {
1360
- const newNormalMap = loadTexture(dataUrl);
1361
- const uniformName = slot + 'NormalMap';
1362
- if (terrainMaterial.uniforms[uniformName]) {
1363
- terrainMaterial.uniforms[uniformName].value.dispose();
1364
- terrainMaterial.uniforms[uniformName].value = newNormalMap;
1365
- }
1366
- });
1367
- }
1368
- terrainMaterial.needsUpdate = true;
1369
- updateTextureUIAfterLoad(result);
1370
- updateChunks();
1371
-
1372
  document.getElementById('project-name').value = projectName;
1373
 
1374
  } catch (error) {
@@ -1380,15 +606,12 @@ EDITOR_TEMPLATE = '''
1380
 
1381
  const moveDirection = new THREE.Vector3();
1382
  function updatePlayer(deltaTime) {
1383
- if (terrainMeshes.length === 0) return;
1384
-
1385
- const speedDelta = deltaTime * playerSpeed;
1386
-
1387
  moveDirection.set(0,0,0);
1388
- if (keyStates['KeyW']) moveDirection.z -= 1;
1389
- if (keyStates['KeyS']) moveDirection.z += 1;
1390
- if (keyStates['KeyA']) moveDirection.x -= 1;
1391
- if (keyStates['KeyD']) moveDirection.x += 1;
1392
 
1393
  if (joystick.active) {
1394
  const maxRadius = 60;
@@ -1396,35 +619,53 @@ EDITOR_TEMPLATE = '''
1396
  moveDirection.z += joystick.vector.y / maxRadius;
1397
  }
1398
 
1399
- if(moveDirection.lengthSq() > 0) {
1400
- moveDirection.normalize();
1401
- moveDirection.applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 4);
1402
- player.position.addScaledVector(moveDirection, speedDelta);
1403
- }
1404
 
1405
- raycaster.set(new THREE.Vector3(player.position.x, 100, player.position.z), new THREE.Vector3(0, -1, 0));
1406
- const intersects = raycaster.intersectObjects(terrainMeshes);
1407
- if (intersects.length > 0) {
1408
- const groundHeight = intersects[0].point.y;
1409
- player.position.y = groundHeight + 1.5;
 
 
 
 
 
1410
  }
 
 
 
 
 
 
 
 
 
 
 
 
1411
 
1412
- camera.position.copy(player.position).add(cameraOffset);
 
 
1413
  camera.lookAt(player.position);
1414
  }
1415
 
 
 
 
 
1416
  function animate() {
1417
  requestAnimationFrame(animate);
1418
  const deltaTime = clock.getDelta();
1419
 
1420
- updateChunks();
1421
-
1422
  if (isPlayMode) {
1423
  updatePlayer(deltaTime);
1424
  } else {
1425
  orbitControls.update();
1426
  }
1427
- composer.render();
1428
  }
1429
 
1430
  init();
@@ -1457,7 +698,7 @@ def save_project_api():
1457
  if success:
1458
  return jsonify({"message": "Project saved successfully"}), 201
1459
  else:
1460
- return jsonify({"error": "Failed to upload project to Hugging Face Hub"}), 500
1461
 
1462
  @app.route('/api/project/<project_name>', methods=['GET'])
1463
  def load_project_api(project_name):
 
8
  from huggingface_hub import HfApi, hf_hub_download
9
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
10
  from dotenv import load_dotenv
 
11
 
12
  load_dotenv()
13
 
14
  app = Flask(__name__)
15
+ app.secret_key = 'level_designer_secret_key_zomboid_5678'
16
 
17
  REPO_ID = "Kgshop/Testai"
18
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
19
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
20
 
 
 
 
21
  def upload_project_to_hf(local_path, project_name):
22
  if not HF_TOKEN_WRITE:
 
23
  return False
24
  try:
25
  api = HfApi()
26
  api.upload_file(
27
  path_or_fileobj=local_path,
28
+ path_in_repo=f"pz_projects/{project_name}.json",
29
  repo_id=REPO_ID,
30
  repo_type="dataset",
31
  token=HF_TOKEN_WRITE,
32
+ commit_message=f"Save PZ project {project_name} at {datetime.now()}"
33
  )
34
  return True
35
  except Exception as e:
36
  print(f"Error uploading {project_name} to HF: {e}")
37
  return False
38
 
39
+ def download_project_from_hf(project_name):
40
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
41
  if not token_to_use:
 
42
  return None
43
+ try:
44
+ local_path = hf_hub_download(
45
+ repo_id=REPO_ID,
46
+ filename=f"pz_projects/{project_name}.json",
47
+ repo_type="dataset",
48
+ token=token_to_use,
49
+ local_dir=".",
50
+ local_dir_use_symlinks=False,
51
+ force_download=True
52
+ )
53
+ with open(local_path, 'r', encoding='utf-8') as f:
54
+ data = json.load(f)
55
+ if os.path.exists(local_path):
56
+ os.remove(local_path)
57
+ return data
58
+ except (HfHubHTTPError, RepositoryNotFoundError) as e:
59
+ return None
60
+ except Exception as e:
61
+ return None
 
 
 
 
 
 
62
 
63
  def list_projects_from_hf():
64
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
 
67
  try:
68
  api = HfApi()
69
  repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset", token=token_to_use)
70
+ project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('pz_projects/') and f.endswith('.json')]
 
71
  return sorted(project_files)
72
+ except Exception:
 
 
 
 
73
  return []
74
 
75
  EDITOR_TEMPLATE = '''
 
78
  <head>
79
  <meta charset="UTF-8">
80
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
81
+ <title>PZ Style Constructor</title>
82
  <style>
83
+ body { margin: 0; overflow: hidden; background-color: #1a1a1a; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
84
  canvas { display: block; }
85
+ #ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; display: flex; }
86
  #ui-panel {
87
+ background: rgba(10, 10, 10, 0.85);
88
+ backdrop-filter: blur(5px);
89
  padding: 15px;
90
  border-right: 1px solid #444;
91
+ width: 320px;
92
  height: 100%;
93
  overflow-y: auto;
94
  box-sizing: border-box;
 
96
  transform: translateX(0);
97
  pointer-events: auto;
98
  }
99
+ .ui-group { margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }
100
  .ui-group:last-child { border-bottom: none; }
101
+ h3 { margin-top: 0; font-size: 1.2em; color: #00aaff; border-bottom: 1px solid #00aaff; padding-bottom: 5px; margin-bottom: 10px; }
102
  label { display: block; margin-bottom: 5px; font-size: 0.9em; }
103
+ input[type="text"], select {
104
  width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; margin-bottom: 5px;
105
  }
106
  button {
107
+ width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px; transition: background-color 0.2s;
108
  }
109
  button:hover { background: #0099ff; }
110
  button.play-button { background: #22aa22; }
111
  button.play-button:hover { background: #33cc33; }
112
+ button.danger-button { background: #c00; }
113
+ button.danger-button:hover { background: #e00; }
114
+ #tool-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; }
115
+ .tool-item {
116
+ padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align: center; cursor: pointer;
117
+ transition: all 0.2s; font-size: 0.9em;
118
+ }
119
+ .tool-item:hover { background: #3a3a3a; border-color: #666; }
120
+ .tool-item.active { background: #0077cc; border-color: #00aaff; }
121
  #loading-spinner {
122
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
123
  border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%;
124
  width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 100;
125
  }
126
+ #blocker { position: absolute; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; }
127
+ #instructions { width: 100%; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; font-size: 14px; cursor: pointer; color: white; }
 
 
 
 
 
 
128
  #burger-menu {
129
+ position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
 
130
  }
131
+ #burger-menu span { display: block; position: absolute; height: 4px; width: 100%; background: white; border-radius: 2px; opacity: 1; left: 0; transition: .25s ease-in-out; }
132
+ #burger-menu span:nth-child(1) { top: 0px; } #burger-menu span:nth-child(2) { top: 9px; } #burger-menu span:nth-child(3) { top: 18px; }
 
 
 
 
 
 
 
133
  #joystick-container {
134
+ position: absolute; bottom: 30px; left: 30px; width: 120px; height: 120px; background: rgba(128, 128, 128, 0.3); border-radius: 50%;
135
+ display: none; z-index: 100; pointer-events: auto; user-select: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  }
137
+ #joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
138
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
139
  @media (max-width: 800px) {
140
  #ui-panel { transform: translateX(-100%); padding-top: 60px; }
141
  #ui-panel.open { transform: translateX(0); }
 
147
  </head>
148
  <body>
149
  <div id="ui-container">
150
+ <div id="burger-menu"><span></span><span></span><span></span></div>
 
 
151
  <div id="ui-panel">
 
 
 
 
152
  <div class="ui-group">
153
  <h3>Проект</h3>
 
154
  <select id="project-list">
155
  <option value="">Выберите проект...</option>
156
  {% for project in projects %}
157
  <option value="{{ project }}">{{ project }}</option>
158
  {% endfor %}
159
  </select>
160
+ <button id="load-project">Загрузить</button>
161
  <hr style="border-color: #333; margin: 15px 0;">
 
162
  <input type="text" id="project-name" placeholder="new-level-01">
163
+ <button id="save-project">Сохранить</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  </div>
165
  <div class="ui-group">
166
+ <h3>Режим</h3>
167
+ <button id="play-mode-toggle" class="play-button">Играть</button>
 
 
 
 
 
 
168
  </div>
169
  <div class="ui-group">
170
+ <h3>Инструменты</h3>
171
+ <div id="tool-selector"></div>
172
+ <p style="font-size: 0.8em; color: #888; margin-top: 10px;">
173
+ ЛКМ: Разместить<br>
174
+ ПКМ: Вращать<br>
175
+ Shift + ЛКМ: Удалить<br>
176
+ Колесо мыши: Вращать
177
+ </p>
178
+ <button id="clear-level" class="danger-button">Очистить уровень</button>
179
  </div>
180
  </div>
181
  </div>
182
+ <div id="blocker"><div id="instructions"><p style="font-size:36px">Нажмите, чтобы играть</p><p>Движение: WASD / Джойстик</p><p>Нажмите ESC для выхода</p></div></div>
 
 
 
 
 
 
 
 
 
 
183
  <div id="loading-spinner"></div>
184
+ <div id="joystick-container"><div id="joystick-handle"></div></div>
 
 
 
185
 
186
  <script type="importmap">
187
+ { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
 
 
 
 
 
 
188
  </script>
 
189
  <script type="module">
190
  import * as THREE from 'three';
191
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
 
 
 
 
 
192
 
193
+ let scene, camera, renderer, orbitControls;
194
+ let raycaster, mouse, placementPlane, gridHelper, previewMesh;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
 
196
  let isPlayMode = false;
197
  let player, playerVelocity = new THREE.Vector3();
198
+ const playerSpeed = 5.0;
199
  const keyStates = {};
200
  const clock = new THREE.Clock();
201
+ let wallBBoxes = [];
202
 
203
+ let currentTool = { category: 'floors', type: 'grass' };
204
+ let currentRotation = 0;
205
+ const gridSize = 1;
206
+ const levelData = { floors: {}, walls: {}, objects: {} };
 
 
207
 
208
+ const joystick = { active: false, center: new THREE.Vector2(), current: new THREE.Vector2(), vector: new THREE.Vector2() };
 
 
 
 
 
209
 
210
+ const ASSETS = {
211
+ floors: {
212
+ grass: { color: 0x55902f, size: [gridSize, 0.1, gridSize] },
213
+ concrete: { color: 0x888888, size: [gridSize, 0.1, gridSize] },
214
+ wood: { color: 0x8B4513, size: [gridSize, 0.1, gridSize] },
215
+ dirt: { color: 0x9b7653, size: [gridSize, 0.1, gridSize] }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  },
217
+ walls: {
218
+ brick: { color: 0xb55a44, size: [gridSize, 2.5, 0.2] },
219
+ concrete: { color: 0xc2c2c2, size: [gridSize, 2.5, 0.2] }
220
+ },
221
+ objects: {
222
+ crate: { color: 0x966F33, size: [gridSize * 0.8, gridSize * 0.8, gridSize * 0.8] },
223
+ barrel: { color: 0x595959, size: [gridSize * 0.4, gridSize * 0.8, 0], geometry: 'cylinder' }
224
+ }
225
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
+ const instancedMeshes = {};
 
 
 
 
228
 
229
  function init() {
230
  scene = new THREE.Scene();
231
+ scene.background = new THREE.Color(0x283747);
232
 
233
+ camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);
234
+ camera.position.set(25, 30, 25);
 
 
 
235
 
236
  renderer = new THREE.WebGLRenderer({ antialias: true });
237
  renderer.setSize(window.innerWidth, window.innerHeight);
238
  renderer.setPixelRatio(window.devicePixelRatio);
239
  renderer.shadowMap.enabled = true;
240
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
 
 
241
  document.body.appendChild(renderer.domElement);
242
 
243
  orbitControls = new OrbitControls(camera, renderer.domElement);
244
  orbitControls.enableDamping = true;
245
+ orbitControls.maxPolarAngle = Math.PI / 2.2;
246
+ orbitControls.minDistance = 10;
247
+ orbitControls.maxDistance = 100;
248
 
249
+ const hemiLight = new THREE.HemisphereLight(0xB1E1FF, 0xB97A20, 0.8);
250
  scene.add(hemiLight);
251
 
252
  const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
253
+ dirLight.position.set(-30, 50, -30);
254
  dirLight.castShadow = true;
255
+ dirLight.shadow.mapSize.width = 2048;
256
+ dirLight.shadow.mapSize.height = 2048;
257
+ dirLight.shadow.camera.left = -50;
258
+ dirLight.shadow.camera.right = 50;
259
+ dirLight.shadow.camera.top = 50;
260
+ dirLight.shadow.camera.bottom = -50;
 
 
261
  scene.add(dirLight);
 
262
 
263
+ gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x444444);
264
+ scene.add(gridHelper);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
+ const planeGeo = new THREE.PlaneGeometry(100, 100);
267
+ planeGeo.rotateX(-Math.PI / 2);
268
+ placementPlane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ visible: false }));
269
+ scene.add(placementPlane);
 
270
 
271
+ raycaster = new THREE.Raycaster();
272
+ mouse = new THREE.Vector2();
273
+
274
+ initUI();
275
  initPlayer();
276
  initJoystick();
277
+ initInstancedMeshes();
278
+
279
  window.addEventListener('resize', onWindowResize);
280
  renderer.domElement.addEventListener('pointermove', onPointerMove);
281
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
282
+ window.addEventListener('keydown', e => { keyStates[e.code] = true; handleKeyDown(e); });
283
+ window.addEventListener('keyup', e => { keyStates[e.code] = false; });
284
+ renderer.domElement.addEventListener('wheel', e => {
285
+ if(!isPlayMode) {
286
+ e.preventDefault();
287
+ rotatePreview(e.deltaY > 0 ? 1 : -1);
288
+ }
289
+ }, { passive: false });
290
+ renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
 
 
 
 
 
 
 
 
291
 
292
+ animate();
 
293
  }
294
 
295
+ function initInstancedMeshes() {
296
+ const MAX_COUNT = 10000;
297
+ for (const category in ASSETS) {
298
+ instancedMeshes[category] = {};
299
+ for (const type in ASSETS[category]) {
300
+ const asset = ASSETS[category][type];
301
+ let geometry;
302
+ if(asset.geometry === 'cylinder') {
303
+ geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
304
+ } else {
305
+ geometry = new THREE.BoxGeometry(...asset.size);
306
+ }
307
+ const material = new THREE.MeshStandardMaterial({ color: asset.color });
308
+ const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);
309
+ mesh.castShadow = true;
310
+ mesh.receiveShadow = true;
311
+ mesh.count = 0;
312
+ instancedMeshes[category][type] = mesh;
313
+ scene.add(mesh);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  }
315
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  }
317
 
318
  function initPlayer() {
319
+ const playerGeo = new THREE.CylinderGeometry(0.25, 0.25, 1.5, 16);
320
+ playerGeo.translate(0, 0.75, 0);
321
+ const playerMat = new THREE.MeshStandardMaterial({ color: 0x00aaff });
322
+ player = new THREE.Mesh(playerGeo, playerMat);
323
+ player.castShadow = true;
324
  player.visible = false;
325
  scene.add(player);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  }
327
+
328
+ function initUI() {
329
+ const toolSelector = document.getElementById('tool-selector');
330
+ toolSelector.innerHTML = '';
331
+ for (const category in ASSETS) {
332
+ for (const type in ASSETS[category]) {
333
+ const item = document.createElement('div');
334
+ item.className = 'tool-item';
335
+ item.textContent = `${category.slice(0,1).toUpperCase()}: ${type}`;
336
+ item.dataset.category = category;
337
+ item.dataset.type = type;
338
+ item.addEventListener('click', () => selectTool(category, type));
339
+ toolSelector.appendChild(item);
340
+ }
341
+ }
342
+ selectTool('floors', 'grass');
343
+
344
  document.getElementById('save-project').addEventListener('click', saveProject);
345
  document.getElementById('load-project').addEventListener('click', loadProject);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
347
+ document.getElementById('clear-level').addEventListener('click', clearLevel);
348
+ document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open'));
349
+ document.getElementById('project-list').addEventListener('change', e => document.getElementById('project-name').value = e.target.value);
 
 
 
 
 
350
  }
351
+
352
  function initJoystick() {
353
  const joystickContainer = document.getElementById('joystick-container');
354
  const joystickHandle = document.getElementById('joystick-handle');
355
+ const maxRadius = joystickContainer.clientWidth / 2;
356
 
357
  function onTouchStart(event) {
 
358
  const touch = event.touches[0];
359
  joystick.active = true;
360
  joystick.center.set(touch.clientX, touch.clientY);
361
+ joystickContainer.style.left = `${touch.clientX - maxRadius}px`;
362
+ joystickContainer.style.top = `${touch.clientY - maxRadius}px`;
363
  }
 
364
  function onTouchMove(event) {
365
  if (!joystick.active) return;
 
366
  const touch = event.touches[0];
367
  joystick.current.set(touch.clientX, touch.clientY);
368
  joystick.vector.copy(joystick.current).sub(joystick.center);
369
+ if (joystick.vector.length() > maxRadius) joystick.vector.setLength(maxRadius);
 
 
 
 
370
  joystickHandle.style.transform = `translate(${joystick.vector.x}px, ${joystick.vector.y}px)`;
371
  }
372
+ function onTouchEnd() {
 
373
  joystick.active = false;
374
  joystick.vector.set(0, 0);
375
  joystickHandle.style.transform = 'translate(0, 0)';
376
  }
377
+ joystickContainer.addEventListener('touchstart', onTouchStart, { passive: true });
378
+ window.addEventListener('touchmove', onTouchMove, { passive: true });
379
  window.addEventListener('touchend', onTouchEnd);
380
  }
381
 
382
+ function selectTool(category, type) {
383
+ currentTool = { category, type };
384
+ document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
385
+ const activeEl = document.querySelector(`.tool-item[data-category="${category}"][data-type="${type}"]`);
386
+ if (activeEl) activeEl.classList.add('active');
387
+
388
+ if (previewMesh) scene.remove(previewMesh);
389
+ const asset = ASSETS[category][type];
390
+ let geometry;
391
+ if(asset.geometry === 'cylinder') {
392
+ geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  } else {
394
+ geometry = new THREE.BoxGeometry(...asset.size);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  }
396
+ const material = new THREE.MeshBasicMaterial({ color: asset.color, transparent: true, opacity: 0.6 });
397
+ previewMesh = new THREE.Mesh(geometry, material);
398
+ scene.add(previewMesh);
399
  }
400
 
401
  function onWindowResize() {
402
+ camera.aspect = window.innerWidth / window.innerHeight;
 
 
 
 
 
403
  camera.updateProjectionMatrix();
404
  renderer.setSize(window.innerWidth, window.innerHeight);
 
 
 
 
 
405
  }
406
 
407
  function onPointerMove(event) {
408
  if (isPlayMode) return;
409
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
410
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
 
 
411
  raycaster.setFromCamera(mouse, camera);
412
+ const intersects = raycaster.intersectObject(placementPlane);
 
413
  if (intersects.length > 0) {
414
+ const point = intersects[0].point;
415
+ const gridX = Math.round(point.x / gridSize);
416
+ const gridZ = Math.round(point.z / gridSize);
417
+ previewMesh.position.set(gridX * gridSize, 0, gridZ * gridSize);
418
+
419
+ const asset = ASSETS[currentTool.category][currentTool.type];
420
+ if (currentTool.category === 'floors') {
421
+ previewMesh.position.y = asset.size[1] / 2;
422
+ } else if (currentTool.category === 'walls') {
423
+ previewMesh.position.y = asset.size[1] / 2;
424
+ } else {
425
+ previewMesh.position.y = asset.size[1] / 2;
426
  }
427
+ previewMesh.visible = true;
428
  } else {
429
+ previewMesh.visible = false;
430
  }
431
  }
432
 
433
+ function handleKeyDown(event) {
 
 
 
 
 
434
  if (isPlayMode) return;
435
+ if (event.code === 'KeyR') {
436
+ rotatePreview(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  }
438
  }
439
 
440
+ function onPointerDown(event) {
441
+ if (isPlayMode || !previewMesh.visible) return;
442
+ const isRemoving = event.shiftKey;
443
+
444
+ if (event.button === 2) { // Right click
445
+ rotatePreview(1);
446
+ return;
 
 
 
447
  }
448
+
449
+ if (event.button !== 0) return; // Not left click
450
+
451
+ const pos = previewMesh.position;
452
+ const key = `${pos.x},${pos.z}`;
 
 
 
 
 
 
 
 
 
 
 
453
 
454
+ if (isRemoving) {
455
+ removeItemAt(pos);
456
+ } else {
457
+ addItem(pos, currentRotation);
 
 
 
 
 
 
 
 
 
 
 
458
  }
459
+ updateLevelGeometry();
460
  }
461
+
462
+ function rotatePreview(direction) {
463
+ currentRotation += (Math.PI / 2) * direction;
464
+ previewMesh.rotation.y = currentRotation;
 
 
 
 
 
 
 
 
 
 
 
465
  }
466
 
467
+ function addItem(pos, rotation) {
468
+ const { category, type } = currentTool;
469
+ const key = `${pos.x},${pos.z}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
 
471
+ removeItemAt(pos);
 
 
 
 
 
 
 
 
472
 
473
+ if (!levelData[category][type]) levelData[category][type] = [];
474
+ levelData[category][type].push([pos.x, pos.z, rotation]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
  }
476
 
477
+ function removeItemAt(pos) {
478
+ const keyToRemove = `${pos.x},${pos.z}`;
479
+ for (const category in levelData) {
480
+ for (const type in levelData[category]) {
481
+ levelData[category][type] = levelData[category][type].filter(item => {
482
+ const itemKey = `${item[0]},${item[1]}`;
483
+ return itemKey !== keyToRemove;
484
+ });
485
+ }
486
+ }
 
 
 
487
  }
488
 
489
+ function updateLevelGeometry() {
490
+ const dummy = new THREE.Object3D();
491
+ wallBBoxes = [];
 
492
 
493
+ for (const category in ASSETS) {
494
+ for (const type in ASSETS[category]) {
495
+ const mesh = instancedMeshes[category][type];
496
+ const dataArray = levelData[category]?.[type] || [];
497
+ mesh.count = dataArray.length;
498
+
499
+ for (let i = 0; i < dataArray.length; i++) {
500
+ const [x, z, rot] = dataArray[i];
501
+ const asset = ASSETS[category][type];
502
+
503
+ dummy.position.set(x, 0, z);
504
+ dummy.rotation.set(0, rot, 0);
505
+
506
+ if (category === 'floors') {
507
+ dummy.position.y = asset.size[1] / 2;
508
+ } else {
509
+ dummy.position.y = asset.size[1] / 2;
510
+ }
511
+
512
+ dummy.updateMatrix();
513
+ mesh.setMatrixAt(i, dummy.matrix);
514
 
515
+ if (category === 'walls') {
516
+ const box = new THREE.Box3().setFromObject(dummy);
517
+ wallBBoxes.push(box);
518
+ }
519
+ }
520
+ mesh.instanceMatrix.needsUpdate = true;
 
 
 
 
 
 
 
 
 
 
 
521
  }
522
+ }
523
+ }
524
+
525
+ function clearLevel() {
526
+ if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
527
+ levelData.floors = {};
528
+ levelData.walls = {};
529
+ levelData.objects = {};
530
+ updateLevelGeometry();
531
  }
532
 
533
+ function togglePlayMode() {
534
+ isPlayMode = !isPlayMode;
535
+ const uiContainer = document.getElementById('ui-container');
536
+ const blocker = document.getElementById('blocker');
537
+ const joystickContainer = document.getElementById('joystick-container');
538
 
539
+ if (isPlayMode) {
540
+ uiContainer.style.display = 'none';
541
+ gridHelper.visible = false;
542
+ previewMesh.visible = false;
543
+ orbitControls.enabled = false;
544
+ player.visible = true;
545
+ player.position.set(0, 0, 0);
546
+ blocker.style.display = 'block';
547
+ joystickContainer.style.display = 'block';
548
+ } else {
549
+ uiContainer.style.display = 'flex';
550
+ gridHelper.visible = true;
551
+ previewMesh.visible = true;
552
+ orbitControls.enabled = true;
553
+ player.visible = false;
554
+ blocker.style.display = 'none';
555
+ joystickContainer.style.display = 'none';
556
+ camera.position.set(25, 30, 25);
557
+ orbitControls.target.set(0, 0, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  }
 
559
  }
560
 
561
  async function saveProject() {
562
  const projectName = document.getElementById('project-name').value.trim();
563
  if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
 
564
  showSpinner(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  try {
566
  const response = await fetch('/api/project', {
567
  method: 'POST',
568
  headers: { 'Content-Type': 'application/json' },
569
+ body: JSON.stringify({ name: projectName, data: levelData })
570
  });
571
+ if (!response.ok) throw new Error('Ошибка при сохранении проекта.');
 
572
  alert(`Проект '${projectName}' успешно сохранен!`);
 
573
  const projectList = document.getElementById('project-list');
574
+ if (![...projectList.options].some(opt => opt.value === projectName)) {
575
  const newOption = document.createElement('option');
576
  newOption.value = newOption.textContent = projectName;
577
  projectList.appendChild(newOption);
 
582
  showSpinner(false);
583
  }
584
  }
585
+
586
  async function loadProject() {
587
  const projectName = document.getElementById('project-list').value;
588
  if (!projectName) { alert("Пожалуйста, выберите проект для загрузки."); return; }
589
  showSpinner(true);
 
590
  try {
591
  const response = await fetch(`/api/project/${projectName}`);
592
  const result = await response.json();
593
+ if (!response.ok) throw new Error('Проект не найден или ошибка загрузки.');
 
 
 
 
 
 
 
594
 
595
+ clearLevel();
596
+ Object.assign(levelData, result.data);
597
+ updateLevelGeometry();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  document.getElementById('project-name').value = projectName;
599
 
600
  } catch (error) {
 
606
 
607
  const moveDirection = new THREE.Vector3();
608
  function updatePlayer(deltaTime) {
609
+ const speed = playerSpeed * deltaTime;
 
 
 
610
  moveDirection.set(0,0,0);
611
+ if (keyStates['KeyW'] || keyStates['ArrowUp']) moveDirection.z -= 1;
612
+ if (keyStates['KeyS'] || keyStates['ArrowDown']) moveDirection.z += 1;
613
+ if (keyStates['KeyA'] || keyStates['ArrowLeft']) moveDirection.x -= 1;
614
+ if (keyStates['KeyD'] || keyStates['ArrowRight']) moveDirection.x += 1;
615
 
616
  if (joystick.active) {
617
  const maxRadius = 60;
 
619
  moveDirection.z += joystick.vector.y / maxRadius;
620
  }
621
 
622
+ if (moveDirection.lengthSq() === 0) return;
623
+ moveDirection.normalize().multiplyScalar(speed);
 
 
 
624
 
625
+ const playerBox = new THREE.Box3().setFromObject(player);
626
+
627
+ const intendedXPos = player.position.clone().add(new THREE.Vector3(moveDirection.x, 0, 0));
628
+ const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0));
629
+ let collisionX = false;
630
+ for(const wallBox of wallBBoxes) {
631
+ if (playerBoxX.intersectsBox(wallBox)) {
632
+ collisionX = true;
633
+ break;
634
+ }
635
  }
636
+ if(!collisionX) player.position.x = intendedXPos.x;
637
+
638
+ const intendedZPos = player.position.clone().add(new THREE.Vector3(0, 0, moveDirection.z));
639
+ const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z));
640
+ let collisionZ = false;
641
+ for(const wallBox of wallBBoxes) {
642
+ if (playerBoxZ.intersectsBox(wallBox)) {
643
+ collisionZ = true;
644
+ break;
645
+ }
646
+ }
647
+ if(!collisionZ) player.position.z = intendedZPos.z;
648
 
649
+ camera.position.x = player.position.x + 15;
650
+ camera.position.z = player.position.z + 15;
651
+ camera.position.y = 20;
652
  camera.lookAt(player.position);
653
  }
654
 
655
+ function showSpinner(show) {
656
+ document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
657
+ }
658
+
659
  function animate() {
660
  requestAnimationFrame(animate);
661
  const deltaTime = clock.getDelta();
662
 
 
 
663
  if (isPlayMode) {
664
  updatePlayer(deltaTime);
665
  } else {
666
  orbitControls.update();
667
  }
668
+ renderer.render(scene, camera);
669
  }
670
 
671
  init();
 
698
  if success:
699
  return jsonify({"message": "Project saved successfully"}), 201
700
  else:
701
+ return jsonify({"error": "Failed to upload project"}), 500
702
 
703
  @app.route('/api/project/<project_name>', methods=['GET'])
704
  def load_project_api(project_name):