Kgshop commited on
Commit
1ff2baf
·
verified ·
1 Parent(s): 906082c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +419 -488
app.py CHANGED
@@ -1,87 +1,85 @@
1
- # --- START OF FILE app.py ---
2
-
3
  import os
4
  import json
5
  import time
6
  from datetime import datetime
7
  from uuid import uuid4
 
8
 
9
- from flask import Flask, render_template_string, request, jsonify, send_file
10
  from huggingface_hub import HfApi, hf_hub_download
11
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
12
  from dotenv import load_dotenv
13
- import io
14
 
15
  load_dotenv()
16
 
17
  app = Flask(__name__)
18
  app.secret_key = 'level_designer_secret_key_zomboid_5678'
 
 
 
19
 
20
  REPO_ID = "Kgshop/Testai"
21
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
22
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
23
 
24
- def get_api():
25
- token = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
26
  if not token:
27
- return None, None
28
- return HfApi(token=token), token
29
 
30
- def upload_project_to_hf(local_path, project_name):
31
- if not HF_TOKEN_WRITE:
 
32
  return False
33
  try:
34
- api = HfApi()
35
  api.upload_file(
36
  path_or_fileobj=local_path,
37
- path_in_repo=f"pz_projects/{project_name}.json",
38
  repo_id=REPO_ID,
39
  repo_type="dataset",
40
- token=HF_TOKEN_WRITE,
41
- commit_message=f"Save PZ project {project_name} at {datetime.now()}"
42
  )
43
  return True
44
  except Exception as e:
45
- print(f"Error uploading {project_name} to HF: {e}")
46
  return False
47
 
48
  def download_project_from_hf(project_name):
49
- api, token = get_api()
50
- if not api: return None
 
51
  try:
52
  local_path = hf_hub_download(
53
  repo_id=REPO_ID,
54
  filename=f"pz_projects/{project_name}.json",
55
  repo_type="dataset",
56
- token=token,
 
 
57
  force_download=True
58
  )
59
  with open(local_path, 'r', encoding='utf-8') as f:
60
  data = json.load(f)
 
 
61
  return data
62
  except (HfHubHTTPError, RepositoryNotFoundError):
63
  return None
64
- except Exception:
 
65
  return None
66
 
67
- def list_projects_from_hf():
68
- api, _ = get_api()
69
- if not api: return []
70
- try:
71
- repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset")
72
- project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('pz_projects/') and f.endswith('.json')]
73
- return sorted(project_files)
74
- except Exception:
75
  return []
76
-
77
- def list_textures_from_hf():
78
- api, _ = get_api()
79
- if not api: return []
80
  try:
81
  repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset")
82
- texture_files = [f.split('/')[-1] for f in repo_info if f.startswith('pz_textures/') and (f.endswith('.png') or f.endswith('.jpg'))]
83
- return sorted(texture_files)
84
- except Exception:
 
85
  return []
86
 
87
  EDITOR_TEMPLATE = '''
@@ -96,53 +94,57 @@ EDITOR_TEMPLATE = '''
96
  canvas { display: block; }
97
  #ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; display: flex; }
98
  #ui-panel {
99
- background: rgba(10, 10, 10, 0.85); backdrop-filter: blur(5px); padding: 15px; border-right: 1px solid #444;
100
- width: 320px; height: 100%; overflow-y: auto; box-sizing: border-box; transition: transform 0.3s ease-in-out;
101
- transform: translateX(0); pointer-events: auto;
 
 
 
 
 
 
 
 
102
  }
103
  .ui-group { margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }
104
  .ui-group:last-child { border-bottom: none; }
105
  h3 { margin-top: 0; font-size: 1.2em; color: #00aaff; border-bottom: 1px solid #00aaff; padding-bottom: 5px; margin-bottom: 10px; }
106
  label { display: block; margin-bottom: 5px; font-size: 0.9em; }
107
  input[type="text"], input[type="number"], select {
108
- width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555;
109
- color: white; border-radius: 4px; margin-bottom: 10px;
110
  }
111
- .input-row { display: flex; gap: 10px; }
112
- .input-row > * { flex: 1; }
113
- .checkbox-label { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
114
  button {
115
- width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px;
116
- cursor: pointer; font-weight: bold; margin-top: 10px; transition: background-color 0.2s;
117
  }
118
  button:hover { background: #0099ff; }
119
- button.play-button { background: #22aa22; } button.play-button:hover { background: #33cc33; }
120
- button.danger-button { background: #c00; } button.danger-button:hover { background: #e00; }
121
- button.secondary-button { background: #555; } button.secondary-button:hover { background: #777; }
122
- .tool-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; }
 
 
123
  .tool-item {
124
- padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align: center;
125
- cursor: pointer; transition: all 0.2s; font-size: 0.9em;
126
  }
127
  .tool-item:hover { background: #3a3a3a; border-color: #666; }
128
  .tool-item.active { background: #0077cc; border-color: #00aaff; }
129
  #loading-spinner {
130
- position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 8px solid #f3f3f3;
131
- border-top: 8px solid #3498db; border-radius: 50%; width: 60px; height: 60px;
132
- animation: spin 1s linear infinite; display: none; z-index: 100;
133
  }
134
- #blocker { position: absolute; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; z-index: 99; }
135
  #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; }
136
  #burger-menu {
137
- position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px;
138
- cursor: pointer; pointer-events: auto;
139
  }
140
  #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; }
141
  #burger-menu span:nth-child(1) { top: 0px; } #burger-menu span:nth-child(2) { top: 9px; } #burger-menu span:nth-child(3) { top: 18px; }
142
  #joystick-container {
143
- position: absolute; bottom: 30px; left: 30px; width: 120px; height: 120px;
144
- background: rgba(128, 128, 128, 0.3); border-radius: 50%; display: none; z-index: 100;
145
- pointer-events: auto; user-select: none;
146
  }
147
  #joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
148
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@@ -150,8 +152,7 @@ EDITOR_TEMPLATE = '''
150
  #ui-panel { transform: translateX(-100%); padding-top: 60px; }
151
  #ui-panel.open { transform: translateX(0); }
152
  #burger-menu { display: block; }
153
- #joystick-container { width: 100px; height: 100px; }
154
- #joystick-handle { width: 50px; height: 50px; top: 25px; left: 25px; }
155
  }
156
  </style>
157
  </head>
@@ -163,53 +164,53 @@ EDITOR_TEMPLATE = '''
163
  <h3>Проект</h3>
164
  <select id="project-list">
165
  <option value="">Выберите проект...</option>
166
- {% for project in projects %}<option value="{{ project }}">{{ project }}</option>{% endfor %}
 
 
167
  </select>
168
  <button id="load-project">Загрузить</button>
169
  <hr style="border-color: #333; margin: 15px 0;">
170
  <input type="text" id="project-name" placeholder="new-level-01">
171
  <button id="save-project">Сохранить</button>
172
  </div>
 
 
 
 
 
173
  <div class="ui-group">
174
  <h3>Режим</h3>
175
- <div class="tool-selector" style="grid-template-columns: 1fr 1fr;">
176
- <div class="tool-item" id="mode-objects">Объекты</div>
177
- <div class="tool-item" id="mode-terrain">Ландшафт</div>
178
- </div>
179
- <button id="play-mode-toggle" class="play-button">Играть</button>
180
  </div>
181
- <div id="objects-panel">
182
- <div class="ui-group">
183
- <h3>Инструменты</h3>
184
- <div class="tool-selector" id="tool-selector"></div>
185
- <label for="object-texture-select">Текстура:</label>
186
- <select id="object-texture-select"></select>
187
- <label class="checkbox-label"><input type="checkbox" id="object-collidable" checked> Препятствие</label>
188
- <label class="checkbox-label"><input type="checkbox" id="object-passable"> Проходимый</label>
189
- <p style="font-size: 0.8em; color: #888; margin-top: 10px;">
190
- ЛКМ: Разместить, Shift + ЛКМ: Удалить, ПКМ/Колесо: Вращать
191
- </p>
192
  </div>
193
- </div>
194
- <div id="terrain-panel" style="display: none;">
195
- <div class="ui-group">
196
- <h3>Ландшафт</h3>
197
- <div class="input-row">
198
- <input type="number" id="terrain-width" value="10" placeholder="Ширина">
199
- <input type="number" id="terrain-height" value="10" placeholder="Высота">
200
- </div>
201
- <label for="terrain-texture-select">Текстура:</label>
202
- <select id="terrain-texture-select"></select>
203
- <button id="create-terrain">Создать ландшафт</button>
204
- <button id="delete-terrain" class="danger-button">Удалить выбранный</button>
205
  </div>
206
  </div>
207
  <div class="ui-group">
 
 
 
 
 
208
  <button id="clear-level" class="danger-button">Очистить уровень</button>
209
  </div>
210
  </div>
211
  </div>
212
- <div id="blocker"><div id="instructions"><p style="font-size:36px">Нажмите, чтобы играть</p><p>Движение: WASD / Джойстик</p><p>Обзор: Мышь</p><p>Нажмите ESC для выхода</p></div></div>
213
  <div id="loading-spinner"></div>
214
  <div id="joystick-container"><div id="joystick-handle"></div></div>
215
 
@@ -219,58 +220,56 @@ EDITOR_TEMPLATE = '''
219
  <script type="module">
220
  import * as THREE from 'three';
221
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
222
- import { TransformControls } from 'three/addons/controls/TransformControls.js';
223
 
224
- let scene, camera, renderer, orbitControls, transformControls;
225
- let raycaster, mouse, placementPlane, previewMesh;
226
 
227
- let isPlayMode = false, editMode = 'objects';
228
- let player, playerVelocity = new THREE.Vector3(), playerOnFloor = false;
229
- const playerSpeed = 5.0, playerHeight = 1.8;
230
  const keyStates = {};
231
  const clock = new THREE.Clock();
232
-
233
  let collisionObjects = [];
234
- let terrainObjects = [];
235
- let selectedTerrain = null;
236
 
237
- let currentTool = { type: 'wall_brick', size: [1, 2.5, 0.2] };
238
  let currentRotation = 0;
239
  const gridSize = 1;
240
- const levelData = { objects: [], terrain: [] };
241
-
242
- const textureCache = {};
243
- const textureLoader = new THREE.TextureLoader();
244
 
245
  const joystick = { active: false, center: new THREE.Vector2(), current: new THREE.Vector2(), vector: new THREE.Vector2() };
246
-
247
- const OBJECT_DEFINITIONS = {
248
- wall_brick: { size: [1, 2.5, 0.2] },
249
- crate: { size: [0.8, 0.8, 0.8] },
250
- barrel: { size: [0.4, 0.8, 0.4], geometry: 'cylinder' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  };
252
- const TEXTURES = {{ textures|tojson }};
253
 
254
- function getTexture(textureName) {
255
- if (textureCache[textureName]) return textureCache[textureName];
256
- const texture = textureLoader.load(`/api/texture/${textureName}`);
257
- texture.wrapS = THREE.RepeatWrapping;
258
- texture.wrapT = THREE.RepeatWrapping;
259
- texture.magFilter = THREE.NearestFilter;
260
- textureCache[textureName] = texture;
261
- return texture;
262
- }
263
 
264
  function init() {
265
  scene = new THREE.Scene();
266
  scene.background = new THREE.Color(0x1d2935);
267
  scene.fog = new THREE.Fog(0x1d2935, 50, 150);
268
 
269
- const aspect = window.innerWidth / window.innerHeight;
270
- const frustumSize = 30;
271
- camera = new THREE.OrthographicCamera(frustumSize * aspect / -2, frustumSize * aspect / 2, frustumSize / 2, frustumSize / -2, 1, 1000);
272
- camera.position.set(50, 50, 50);
273
- camera.lookAt(0, 0, 0);
274
 
275
  renderer = new THREE.WebGLRenderer({ antialias: true });
276
  renderer.setSize(window.innerWidth, window.innerHeight);
@@ -281,26 +280,14 @@ EDITOR_TEMPLATE = '''
281
 
282
  orbitControls = new OrbitControls(camera, renderer.domElement);
283
  orbitControls.enableDamping = true;
284
- orbitControls.maxZoom = 5;
285
- orbitControls.minZoom = 0.2;
286
-
287
- transformControls = new TransformControls(camera, renderer.domElement);
288
- transformControls.addEventListener('dragging-changed', event => { orbitControls.enabled = !event.value; });
289
- transformControls.addEventListener('objectChange', () => {
290
- if (selectedTerrain) {
291
- const terrainData = levelData.terrain.find(t => t.id === selectedTerrain.userData.id);
292
- if(terrainData) {
293
- terrainData.position = selectedTerrain.position.toArray();
294
- terrainData.rotation = selectedTerrain.rotation.toArray().slice(0, 3);
295
- }
296
- }
297
- });
298
- scene.add(transformControls);
299
 
300
- const hemiLight = new THREE.HemisphereLight(0xB1E1FF, 0xB97A20, 0.6);
301
  scene.add(hemiLight);
302
 
303
- const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
304
  dirLight.position.set(-30, 50, -30);
305
  dirLight.castShadow = true;
306
  dirLight.shadow.mapSize.width = 2048;
@@ -311,7 +298,7 @@ EDITOR_TEMPLATE = '''
311
  dirLight.shadow.camera.bottom = -50;
312
  scene.add(dirLight);
313
 
314
- const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x444444);
315
  scene.add(gridHelper);
316
 
317
  const planeGeo = new THREE.PlaneGeometry(100, 100);
@@ -325,55 +312,79 @@ EDITOR_TEMPLATE = '''
325
  initUI();
326
  initPlayer();
327
  initJoystick();
 
328
 
329
  window.addEventListener('resize', onWindowResize);
330
  renderer.domElement.addEventListener('pointermove', onPointerMove);
331
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
332
- window.addEventListener('keydown', e => { keyStates[e.code] = true; handleKeyDown(e); });
333
  window.addEventListener('keyup', e => { keyStates[e.code] = false; });
334
  renderer.domElement.addEventListener('wheel', e => {
335
- if(editMode === 'objects' && !isPlayMode) {
336
- e.preventDefault();
337
- rotatePreview(e.deltaY > 0 ? 1 : -1);
338
- }
339
  }, { passive: false });
340
  renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
341
 
342
  animate();
343
  }
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  function initPlayer() {
346
- const playerGeo = new THREE.CapsuleGeometry(0.3, playerHeight - 0.6);
347
- playerGeo.translate(0, (playerHeight - 0.6) / 2 + 0.3, 0);
348
- const playerMat = new THREE.MeshStandardMaterial({ color: 0x00aaff, visible: false });
349
  player = new THREE.Mesh(playerGeo, playerMat);
 
350
  player.visible = false;
351
  scene.add(player);
352
  }
353
 
354
  function initUI() {
355
  const toolSelector = document.getElementById('tool-selector');
356
- for (const type in OBJECT_DEFINITIONS) {
357
- const item = document.createElement('div');
358
- item.className = 'tool-item';
359
- item.textContent = type;
360
- item.dataset.type = type;
361
- item.addEventListener('click', () => selectTool(type));
362
- toolSelector.appendChild(item);
 
 
 
363
  }
364
-
365
- const objectTextureSelect = document.getElementById('object-texture-select');
366
- const terrainTextureSelect = document.getElementById('terrain-texture-select');
367
- TEXTURES.forEach(tex => {
368
- const option1 = document.createElement('option');
369
- option1.value = option1.textContent = tex;
370
- objectTextureSelect.appendChild(option1);
371
- const option2 = document.createElement('option');
372
- option2.value = option2.textContent = tex;
373
- terrainTextureSelect.appendChild(option2);
374
- });
375
-
376
- selectTool(Object.keys(OBJECT_DEFINITIONS)[0]);
377
 
378
  document.getElementById('save-project').addEventListener('click', saveProject);
379
  document.getElementById('load-project').addEventListener('click', loadProject);
@@ -381,23 +392,10 @@ EDITOR_TEMPLATE = '''
381
  document.getElementById('clear-level').addEventListener('click', clearLevel);
382
  document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open'));
383
  document.getElementById('project-list').addEventListener('change', e => document.getElementById('project-name').value = e.target.value);
384
-
385
- document.getElementById('mode-objects').addEventListener('click', () => setEditMode('objects'));
386
- document.getElementById('mode-terrain').addEventListener('click', () => setEditMode('terrain'));
387
-
388
- document.getElementById('create-terrain').addEventListener('click', createTerrain);
389
- document.getElementById('delete-terrain').addEventListener('click', deleteSelectedTerrain);
390
-
391
- document.getElementById('blocker').addEventListener('pointerdown', () => {
392
- if (isPlayMode) {
393
- document.body.requestPointerLock();
394
- }
395
- });
396
- document.addEventListener('pointerlockchange', () => {
397
- if (document.pointerLockElement === document.body) {
398
- blocker.style.display = 'none';
399
- } else {
400
- if (isPlayMode) blocker.style.display = 'flex';
401
  }
402
  });
403
  }
@@ -408,16 +406,14 @@ EDITOR_TEMPLATE = '''
408
  const maxRadius = joystickContainer.clientWidth / 2;
409
 
410
  function onTouchStart(event) {
411
- event.preventDefault();
412
  const touch = event.touches[0];
413
  joystick.active = true;
414
  joystick.center.set(touch.clientX, touch.clientY);
415
- joystickContainer.style.left = `${touch.clientX - maxRadius}px`;
416
- joystickContainer.style.top = `${touch.clientY - maxRadius}px`;
417
  }
418
  function onTouchMove(event) {
 
419
  event.preventDefault();
420
- if (!joystick.active) return;
421
  const touch = event.touches[0];
422
  joystick.current.set(touch.clientX, touch.clientY);
423
  joystick.vector.copy(joystick.current).sub(joystick.center);
@@ -429,245 +425,197 @@ EDITOR_TEMPLATE = '''
429
  joystick.vector.set(0, 0);
430
  joystickHandle.style.transform = 'translate(0, 0)';
431
  }
432
- joystickContainer.addEventListener('touchstart', onTouchStart, { passive: false });
433
- window.addEventListener('touchmove', onTouchMove, { passive: false });
434
- window.addEventListener('touchend', onTouchEnd);
435
  }
436
 
437
- function setEditMode(mode) {
438
- editMode = mode;
439
- transformControls.detach();
440
- selectedTerrain = null;
441
- document.getElementById('objects-panel').style.display = mode === 'objects' ? 'block' : 'none';
442
- document.getElementById('terrain-panel').style.display = mode === 'terrain' ? 'block' : 'none';
443
- document.getElementById('mode-objects').classList.toggle('active', mode === 'objects');
444
- document.getElementById('mode-terrain').classList.toggle('active', mode === 'terrain');
445
- previewMesh.visible = mode === 'objects';
446
- }
447
-
448
- function selectTool(type) {
449
- const definition = OBJECT_DEFINITIONS[type];
450
- if (!definition) return;
451
- currentTool = { type, size: definition.size };
452
  document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
453
- const activeEl = document.querySelector(`.tool-item[data-type="${type}"]`);
454
- if (activeEl) activeEl.classList.add('active');
455
 
456
  if (previewMesh) scene.remove(previewMesh);
 
457
  let geometry;
458
- if(definition.geometry === 'cylinder') {
459
- geometry = new THREE.CylinderGeometry(definition.size[0], definition.size[0], definition.size[1], 16);
460
  } else {
461
- geometry = new THREE.BoxGeometry(...definition.size);
 
 
462
  }
463
- const material = new THREE.MeshBasicMaterial({ color: 0x00aaff, transparent: true, opacity: 0.6, wireframe: true });
 
464
  previewMesh = new THREE.Mesh(geometry, material);
465
  scene.add(previewMesh);
466
- setEditMode('objects');
 
 
 
 
 
467
  }
468
 
469
  function onWindowResize() {
470
- const aspect = window.innerWidth / window.innerHeight;
471
- const frustumSize = 30;
472
- camera.left = frustumSize * aspect / -2;
473
- camera.right = frustumSize * aspect / 2;
474
- camera.top = frustumSize / 2;
475
- camera.bottom = frustumSize / -2;
476
  camera.updateProjectionMatrix();
477
  renderer.setSize(window.innerWidth, window.innerHeight);
478
  }
479
 
480
  function onPointerMove(event) {
481
- if (isPlayMode || editMode !== 'objects') return;
482
- mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
483
- mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
484
- }
485
-
486
- function handleKeyDown(event) {
487
- if (isPlayMode) return;
488
- if (event.code === 'KeyR' && editMode === 'objects') { rotatePreview(1); }
489
- if (event.code === 'KeyG' && editMode === 'terrain') { transformControls.setMode("translate"); }
490
- if (event.code === 'KeyR' && editMode === 'terrain') { transformControls.setMode("rotate"); }
491
- if (event.code === 'Delete' && selectedTerrain) { deleteSelectedTerrain(); }
492
- }
493
-
494
- function onPointerDown(event) {
495
  if (isPlayMode) return;
496
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
497
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
498
  raycaster.setFromCamera(mouse, camera);
499
-
500
- if (editMode === 'objects') {
501
- if (event.target.tagName !== 'CANVAS') return;
502
- const intersects = raycaster.intersectObject(placementPlane);
503
- if (intersects.length > 0) {
504
- const isRemoving = event.shiftKey;
505
- if (event.button === 2) { rotatePreview(1); return; }
506
- if (event.button !== 0) return;
507
-
508
- const pos = previewMesh.position;
509
- if (isRemoving) {
510
- removeObjectAt(pos);
511
- } else {
512
- addObject();
513
- }
514
- buildLevelFromData();
515
- }
516
- } else if (editMode === 'terrain') {
517
- const intersects = raycaster.intersectObjects(terrainObjects);
518
- if (intersects.length > 0 && intersects[0].object !== selectedTerrain) {
519
- selectedTerrain = intersects[0].object;
520
- transformControls.attach(selectedTerrain);
521
- } else if (intersects.length === 0) {
522
- selectedTerrain = null;
523
- transformControls.detach();
524
- }
525
- }
526
- }
527
-
528
- function updatePreviewMesh() {
529
- if (isPlayMode || editMode !== 'objects') {
530
- previewMesh.visible = false;
531
- return;
532
- }
533
- raycaster.setFromCamera(mouse, camera);
534
  const intersects = raycaster.intersectObject(placementPlane);
535
  if (intersects.length > 0) {
536
  const point = intersects[0].point;
537
  const gridX = Math.round(point.x / gridSize);
538
  const gridZ = Math.round(point.z / gridSize);
539
- previewMesh.position.set(gridX * gridSize, currentTool.size[1] / 2, gridZ * gridSize);
 
 
 
 
540
  previewMesh.visible = true;
541
  } else {
542
  previewMesh.visible = false;
543
  }
544
  }
545
 
546
- function rotatePreview(direction) {
547
- currentRotation += (Math.PI / 2) * direction;
548
- previewMesh.rotation.y = currentRotation;
 
 
 
 
549
  }
 
 
 
 
550
 
551
- function addObject() {
552
- const pos = previewMesh.position;
553
- const key = `${pos.x},${pos.y},${pos.z}`;
554
 
555
- levelData.objects = levelData.objects.filter(obj => `${obj.position[0]},${obj.position[1]},${obj.position[2]}` !== key);
 
556
 
557
- const newObject = {
558
- id: THREE.MathUtils.generateUUID(),
559
- type: currentTool.type,
560
- position: pos.toArray(),
561
- rotation: previewMesh.rotation.toArray().slice(0, 3),
562
- texture: document.getElementById('object-texture-select').value,
563
- collidable: document.getElementById('object-collidable').checked,
564
- passable: document.getElementById('object-passable').checked
565
- };
566
- levelData.objects.push(newObject);
567
- }
568
 
569
- function removeObjectAt(pos) {
570
- const key = `${pos.x},${pos.y},${pos.z}`;
571
- levelData.objects = levelData.objects.filter(obj => `${obj.position[0]},${obj.position[1]},${obj.position[2]}` !== key);
 
 
 
572
  }
573
 
574
- function createTerrain() {
575
- const width = parseFloat(document.getElementById('terrain-width').value);
576
- const height = parseFloat(document.getElementById('terrain-height').value);
577
- const texture = document.getElementById('terrain-texture-select').value;
578
- if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0 || !texture) {
579
- alert("Неверные параметры для ландшафта."); return;
580
- }
581
- const terrainData = {
 
 
582
  id: THREE.MathUtils.generateUUID(),
583
- size: [width, height],
584
- position: [0, 0, 0],
585
- rotation: [-Math.PI / 2, 0, 0],
586
- texture: texture
 
587
  };
588
- levelData.terrain.push(terrainData);
589
- buildLevelFromData();
590
- }
591
 
592
- function deleteSelectedTerrain() {
593
- if (!selectedTerrain) return;
594
- levelData.terrain = levelData.terrain.filter(t => t.id !== selectedTerrain.userData.id);
595
- transformControls.detach();
596
- selectedTerrain = null;
597
- buildLevelFromData();
 
598
  }
599
 
600
- function clearScene() {
601
- while(scene.children.length > 0){
602
- const obj = scene.children[0];
603
- if (obj.isLight || obj.isCamera || obj.isGridHelper || obj === placementPlane || obj === transformControls || obj === player || obj === previewMesh) {
604
- scene.children.splice(0, 1);
605
- continue;
606
- }
607
- if(obj.geometry) obj.geometry.dispose();
608
- if(obj.material) {
609
- if (Array.isArray(obj.material)) {
610
- obj.material.forEach(mat => mat.dispose());
611
- } else {
612
- obj.material.dispose();
613
- }
614
- }
615
- scene.remove(obj);
616
- }
617
  }
618
 
619
- function buildLevelFromData() {
620
- clearScene();
621
  collisionObjects = [];
622
- terrainObjects = [];
623
 
624
- levelData.objects.forEach(objData => {
625
- const definition = OBJECT_DEFINITIONS[objData.type];
626
- if (!definition) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
 
628
- let geometry;
629
- if(definition.geometry === 'cylinder') {
630
- geometry = new THREE.CylinderGeometry(definition.size[0], definition.size[0], definition.size[1], 16);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  } else {
632
- geometry = new THREE.BoxGeometry(...definition.size);
633
  }
634
- const material = new THREE.MeshStandardMaterial({ map: getTexture(objData.texture) });
635
- const mesh = new THREE.Mesh(geometry, material);
636
- mesh.position.fromArray(objData.position);
637
- mesh.rotation.fromArray(objData.rotation);
638
- mesh.castShadow = true;
639
- mesh.receiveShadow = true;
640
- scene.add(mesh);
641
-
642
- if (objData.collidable && !objData.passable) {
643
- mesh.updateMatrixWorld();
644
- const box = new THREE.Box3().setFromObject(mesh);
645
  collisionObjects.push(box);
646
  }
647
- });
648
-
649
- levelData.terrain.forEach(terrainData => {
650
- const geometry = new THREE.PlaneGeometry(terrainData.size[0], terrainData.size[1]);
651
- const texture = getTexture(terrainData.texture);
652
- texture.repeat.set(terrainData.size[0] / 2, terrainData.size[1] / 2);
653
- const material = new THREE.MeshStandardMaterial({ map: texture });
654
- const mesh = new THREE.Mesh(geometry, material);
655
- mesh.position.fromArray(terrainData.position);
656
- mesh.rotation.fromArray(terrainData.rotation);
657
- mesh.receiveShadow = true;
658
- mesh.userData.id = terrainData.id;
659
- scene.add(mesh);
660
- terrainObjects.push(mesh);
661
- });
662
  }
663
 
664
  function clearLevel() {
665
  if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
666
- levelData.objects = [];
667
- levelData.terrain = [];
668
- transformControls.detach();
669
- selectedTerrain = null;
670
- buildLevelFromData();
671
  }
672
 
673
  function togglePlayMode() {
@@ -678,32 +626,23 @@ EDITOR_TEMPLATE = '''
678
 
679
  if (isPlayMode) {
680
  uiContainer.style.display = 'none';
 
681
  previewMesh.visible = false;
682
- transformControls.detach();
683
  orbitControls.enabled = false;
684
  player.visible = true;
685
- player.position.set(0, playerHeight, 0);
686
  blocker.style.display = 'flex';
687
  joystickContainer.style.display = 'block';
688
- camera.isPerspectiveCamera = true;
689
- camera.fov = 75;
690
- camera.aspect = window.innerWidth / window.innerHeight;
691
- camera.near = 0.1;
692
- camera.far = 1000;
693
- camera.updateProjectionMatrix();
694
-
695
  } else {
696
- if (document.pointerLockElement === document.body) document.exitPointerLock();
697
  uiContainer.style.display = 'flex';
698
- setEditMode(editMode);
 
699
  orbitControls.enabled = true;
700
  player.visible = false;
701
  blocker.style.display = 'none';
702
  joystickContainer.style.display = 'none';
703
- camera.isOrthographicCamera = true;
704
- onWindowResize();
705
- camera.position.set(50, 50, 50);
706
- camera.lookAt(0, 0, 0);
707
  }
708
  }
709
 
@@ -741,9 +680,9 @@ EDITOR_TEMPLATE = '''
741
  const result = await response.json();
742
  if (!response.ok) throw new Error('Проект не найден или ошибка загрузки.');
743
 
744
- clearLevel();
745
  Object.assign(levelData, result.data);
746
- buildLevelFromData();
747
  document.getElementById('project-name').value = projectName;
748
 
749
  } catch (error) {
@@ -753,83 +692,68 @@ EDITOR_TEMPLATE = '''
753
  }
754
  }
755
 
756
- const moveDirection = new THREE.Vector3();
757
- const playerCollider = new THREE.Box3();
758
- const playerCamera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
759
- scene.add(playerCamera);
760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  function updatePlayer(deltaTime) {
762
- if (document.pointerLockElement !== document.body && !joystick.active) return;
763
-
764
  const speed = playerSpeed * deltaTime;
765
- const cameraDirection = new THREE.Vector3();
766
- playerCamera.getWorldDirection(cameraDirection);
767
- cameraDirection.y = 0;
768
- cameraDirection.normalize();
769
-
770
  moveDirection.set(0,0,0);
771
- if (keyStates['KeyW'] || keyStates['ArrowUp']) moveDirection.add(cameraDirection);
772
- if (keyStates['KeyS'] || keyStates['ArrowDown']) moveDirection.sub(cameraDirection);
773
-
774
- const rightDirection = new THREE.Vector3().crossVectors(playerCamera.up, cameraDirection).negate();
775
- if (keyStates['KeyA'] || keyStates['ArrowLeft']) moveDirection.sub(rightDirection);
776
- if (keyStates['KeyD'] || keyStates['ArrowRight']) moveDirection.add(rightDirection);
777
 
778
  if (joystick.active) {
779
  const maxRadius = 60;
780
- const joystickForward = cameraDirection.clone().multiplyScalar(joystick.vector.y / maxRadius);
781
- const joystickRight = rightDirection.clone().multiplyScalar(joystick.vector.x / maxRadius);
782
- moveDirection.add(joystickForward).add(joystickRight);
783
  }
784
 
785
- if (moveDirection.lengthSq() > 0) {
786
- moveDirection.normalize().multiplyScalar(speed);
787
-
788
- const oldPosition = player.position.clone();
789
-
790
- player.position.x += moveDirection.x;
791
- playerCollider.setFromObject(player);
792
- for (const box of collisionObjects) {
793
- if (playerCollider.intersectsBox(box)) {
794
- player.position.x = oldPosition.x;
795
- break;
796
- }
797
- }
798
-
799
- player.position.z += moveDirection.z;
800
- playerCollider.setFromObject(player);
801
- for (const box of collisionObjects) {
802
- if (playerCollider.intersectsBox(box)) {
803
- player.position.z = oldPosition.z;
804
- break;
805
- }
806
- }
807
- }
808
 
809
- player.position.y -= 9.8 * deltaTime;
810
- playerCollider.setFromObject(player);
811
- let onGround = false;
812
- for(const terrain of terrainObjects) {
813
- const terrainBox = new THREE.Box3().setFromObject(terrain);
814
- if (playerCollider.intersectsBox(terrainBox)) {
815
- player.position.y = terrain.position.y + playerHeight / 2 - 0.5;
816
- onGround = true;
817
- break;
818
- }
819
  }
820
 
821
- if (keyStates['Space'] && onGround) {
822
- player.position.y += 3 * deltaTime;
 
 
823
  }
824
 
825
- playerCamera.position.copy(player.position);
826
- playerCamera.position.y += playerHeight / 2 - 0.2;
827
-
828
- if (document.pointerLockElement === document.body) {
829
- playerCamera.rotation.y -= (event.movementX || 0) * 0.002;
830
- playerCamera.rotation.x -= (event.movementY || 0) * 0.002;
831
- playerCamera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, playerCamera.rotation.x));
832
- }
833
  }
834
 
835
  function showSpinner(show) {
@@ -840,14 +764,12 @@ EDITOR_TEMPLATE = '''
840
  requestAnimationFrame(animate);
841
  const deltaTime = clock.getDelta();
842
 
843
- if (isPlayMode) {
844
  updatePlayer(deltaTime);
845
- renderer.render(scene, playerCamera);
846
- } else {
847
- updatePreviewMesh();
848
  orbitControls.update();
849
- renderer.render(scene, camera);
850
  }
 
851
  }
852
 
853
  init();
@@ -858,9 +780,9 @@ EDITOR_TEMPLATE = '''
858
 
859
  @app.route('/')
860
  def editor():
861
- projects = list_projects_from_hf()
862
- textures = list_textures_from_hf()
863
- return render_template_string(EDITOR_TEMPLATE, projects=projects, textures=textures)
864
 
865
  @app.route('/api/project', methods=['POST'])
866
  def save_project_api():
@@ -870,13 +792,18 @@ def save_project_api():
870
  return jsonify({"error": "Project name is required"}), 400
871
 
872
  local_filename = f"{uuid4().hex}.json"
873
- with open(local_filename, 'w', encoding='utf-8') as f:
 
874
  json.dump(data, f)
875
 
876
- success = upload_project_to_hf(local_filename, project_name)
877
-
878
- if os.path.exists(local_filename):
879
- os.remove(local_filename)
 
 
 
 
880
 
881
  if success:
882
  return jsonify({"message": "Project saved successfully"}), 201
@@ -891,29 +818,33 @@ def load_project_api(project_name):
891
  else:
892
  return jsonify({"error": "Project not found or failed to download"}), 404
893
 
894
- @app.route('/api/textures')
895
- def get_textures_list():
896
- textures = list_textures_from_hf()
897
- return jsonify(textures)
898
-
899
- @app.route('/api/texture/<filename>')
900
- def get_texture_file(filename):
901
- api, token = get_api()
902
- if not api:
903
- return "Hugging Face token not configured", 500
904
- try:
905
- file_path = hf_hub_download(
906
- repo_id=REPO_ID,
907
- filename=f"pz_textures/{filename}",
908
- repo_type="dataset",
909
- token=token,
910
- force_download=True
911
  )
912
- return send_file(file_path)
913
- except HfHubHTTPError:
914
- return "Texture not found", 404
915
- except Exception as e:
916
- return f"Error downloading texture: {e}", 500
 
 
 
 
 
917
 
918
  if __name__ == '__main__':
919
  port = int(os.environ.get('PORT', 7860))
 
 
 
1
  import os
2
  import json
3
  import time
4
  from datetime import datetime
5
  from uuid import uuid4
6
+ from werkzeug.utils import secure_filename
7
 
8
+ from flask import Flask, render_template_string, request, jsonify
9
  from huggingface_hub import HfApi, hf_hub_download
10
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
11
  from dotenv import load_dotenv
 
12
 
13
  load_dotenv()
14
 
15
  app = Flask(__name__)
16
  app.secret_key = 'level_designer_secret_key_zomboid_5678'
17
+ app.config['UPLOAD_FOLDER'] = 'temp_uploads'
18
+
19
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
20
 
21
  REPO_ID = "Kgshop/Testai"
22
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
23
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
24
 
25
+ def hf_api_client(write=False):
26
+ token = HF_TOKEN_WRITE if write else (HF_TOKEN_READ or HF_TOKEN_WRITE)
27
  if not token:
28
+ return None
29
+ return HfApi(token=token)
30
 
31
+ def upload_to_hf(local_path, path_in_repo, commit_message):
32
+ api = hf_api_client(write=True)
33
+ if not api:
34
  return False
35
  try:
 
36
  api.upload_file(
37
  path_or_fileobj=local_path,
38
+ path_in_repo=path_in_repo,
39
  repo_id=REPO_ID,
40
  repo_type="dataset",
41
+ commit_message=commit_message
 
42
  )
43
  return True
44
  except Exception as e:
45
+ print(f"Error uploading to HF: {e}")
46
  return False
47
 
48
  def download_project_from_hf(project_name):
49
+ token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
50
+ if not token_to_use:
51
+ return None
52
  try:
53
  local_path = hf_hub_download(
54
  repo_id=REPO_ID,
55
  filename=f"pz_projects/{project_name}.json",
56
  repo_type="dataset",
57
+ token=token_to_use,
58
+ local_dir=".",
59
+ local_dir_use_symlinks=False,
60
  force_download=True
61
  )
62
  with open(local_path, 'r', encoding='utf-8') as f:
63
  data = json.load(f)
64
+ if os.path.exists(local_path):
65
+ os.remove(local_path)
66
  return data
67
  except (HfHubHTTPError, RepositoryNotFoundError):
68
  return None
69
+ except Exception as e:
70
+ print(f"Error downloading project: {e}")
71
  return None
72
 
73
+ def list_files_from_hf(path_prefix):
74
+ api = hf_api_client()
75
+ if not api:
 
 
 
 
 
76
  return []
 
 
 
 
77
  try:
78
  repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset")
79
+ files = [f.split('/')[-1] for f in repo_info if f.startswith(path_prefix)]
80
+ return sorted(files)
81
+ except Exception as e:
82
+ print(f"Error listing files from {path_prefix}: {e}")
83
  return []
84
 
85
  EDITOR_TEMPLATE = '''
 
94
  canvas { display: block; }
95
  #ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; display: flex; }
96
  #ui-panel {
97
+ background: rgba(10, 10, 10, 0.85);
98
+ backdrop-filter: blur(5px);
99
+ padding: 15px;
100
+ border-right: 1px solid #444;
101
+ width: 320px;
102
+ height: 100%;
103
+ overflow-y: auto;
104
+ box-sizing: border-box;
105
+ transition: transform 0.3s ease-in-out;
106
+ transform: translateX(0);
107
+ pointer-events: auto;
108
  }
109
  .ui-group { margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; }
110
  .ui-group:last-child { border-bottom: none; }
111
  h3 { margin-top: 0; font-size: 1.2em; color: #00aaff; border-bottom: 1px solid #00aaff; padding-bottom: 5px; margin-bottom: 10px; }
112
  label { display: block; margin-bottom: 5px; font-size: 0.9em; }
113
  input[type="text"], input[type="number"], select {
114
+ width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; margin-bottom: 5px;
 
115
  }
116
+ input[type="file"] { padding: 3px; }
 
 
117
  button {
118
+ 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;
 
119
  }
120
  button:hover { background: #0099ff; }
121
+ button:disabled { background: #555; cursor: not-allowed; }
122
+ button.play-button { background: #22aa22; }
123
+ button.play-button:hover { background: #33cc33; }
124
+ button.danger-button { background: #c00; }
125
+ button.danger-button:hover { background: #e00; }
126
+ #tool-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; }
127
  .tool-item {
128
+ padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align: center; cursor: pointer;
129
+ transition: all 0.2s; font-size: 0.9em;
130
  }
131
  .tool-item:hover { background: #3a3a3a; border-color: #666; }
132
  .tool-item.active { background: #0077cc; border-color: #00aaff; }
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 { position: fixed; width: 100%; height: 100%; top:0; left:0; background-color: rgba(0,0,0,0.7); display: none; z-index: 50; }
139
  #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; }
140
  #burger-menu {
141
+ position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto;
 
142
  }
143
  #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; }
144
  #burger-menu span:nth-child(1) { top: 0px; } #burger-menu span:nth-child(2) { top: 9px; } #burger-menu span:nth-child(3) { top: 18px; }
145
  #joystick-container {
146
+ position: absolute; bottom: 30px; left: 30px; width: 120px; height: 120px; background: rgba(128, 128, 128, 0.3); border-radius: 50%;
147
+ display: none; z-index: 100; pointer-events: auto; user-select: none;
 
148
  }
149
  #joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; }
150
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
152
  #ui-panel { transform: translateX(-100%); padding-top: 60px; }
153
  #ui-panel.open { transform: translateX(0); }
154
  #burger-menu { display: block; }
155
+ #joystick-container { display: block; }
 
156
  }
157
  </style>
158
  </head>
 
164
  <h3>Проект</h3>
165
  <select id="project-list">
166
  <option value="">Выберите проект...</option>
167
+ {% for project in projects %}
168
+ <option value="{{ project }}">{{ project }}</option>
169
+ {% endfor %}
170
  </select>
171
  <button id="load-project">Загрузить</button>
172
  <hr style="border-color: #333; margin: 15px 0;">
173
  <input type="text" id="project-name" placeholder="new-level-01">
174
  <button id="save-project">Сохранить</button>
175
  </div>
176
+ <div class="ui-group">
177
+ <h3>Текстуры</h3>
178
+ <input type="file" id="texture-file-input" accept="image/png, image/jpeg">
179
+ <button id="upload-texture-btn">Загрузить текстуру</button>
180
+ </div>
181
  <div class="ui-group">
182
  <h3>Режим</h3>
183
+ <button id="play-mode-toggle" class="play-button">Играть</button>
 
 
 
 
184
  </div>
185
+ <div id="tool-properties" class="ui-group">
186
+ <h3>Параметры инструмента</h3>
187
+ <div id="prop-texture">
188
+ <label for="texture-select">Текстура:</label>
189
+ <select id="texture-select">
190
+ <option value="">Цвет по умолчанию</option>
191
+ {% for texture in textures %}
192
+ <option value="{{ texture }}">{{ texture }}</option>
193
+ {% endfor %}
194
+ </select>
 
195
  </div>
196
+ <div id="prop-terrain-size">
197
+ <label for="terrain-width">Ширина (X):</label>
198
+ <input type="number" id="terrain-width" value="1" min="1">
199
+ <label for="terrain-depth">Глубина (Z):</label>
200
+ <input type="number" id="terrain-depth" value="1" min="1">
 
 
 
 
 
 
 
201
  </div>
202
  </div>
203
  <div class="ui-group">
204
+ <h3>Инструменты</h3>
205
+ <div id="tool-selector"></div>
206
+ <p style="font-size: 0.8em; color: #888; margin-top: 10px;">
207
+ ЛКМ: Разместить<br>ПКМ / Колесо: Вращать<br>Shift + ЛКМ: Удалить
208
+ </p>
209
  <button id="clear-level" class="danger-button">Очистить уровень</button>
210
  </div>
211
  </div>
212
  </div>
213
+ <div id="blocker"><div id="instructions"><p style="font-size:36px">Нажмите, чтобы играть</p><p>Движение: WASD / Джойстик<br>Покинуть режим: ESC</p></div></div>
214
  <div id="loading-spinner"></div>
215
  <div id="joystick-container"><div id="joystick-handle"></div></div>
216
 
 
220
  <script type="module">
221
  import * as THREE from 'three';
222
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
223
 
224
+ let scene, camera, renderer, orbitControls;
225
+ let raycaster, mouse, placementPlane, gridHelper, previewMesh;
226
 
227
+ let isPlayMode = false;
228
+ let player, playerVelocity = new THREE.Vector3();
229
+ const playerSpeed = 5.0;
230
  const keyStates = {};
231
  const clock = new THREE.Clock();
 
232
  let collisionObjects = [];
 
 
233
 
234
+ let currentTool = { category: 'floors', type: 'grass' };
235
  let currentRotation = 0;
236
  const gridSize = 1;
237
+ const levelData = { items: [] };
 
 
 
238
 
239
  const joystick = { active: false, center: new THREE.Vector2(), current: new THREE.Vector2(), vector: new THREE.Vector2() };
240
+
241
+ const textureLoader = new THREE.TextureLoader();
242
+ const textureCache = {};
243
+ const HF_REPO_URL = `https://huggingface.co/datasets/{{ REPO_ID }}/resolve/main/`;
244
+
245
+ const ASSETS = {
246
+ floors: {
247
+ grass: { color: 0x55902f, size: [gridSize, 0.1, gridSize] },
248
+ concrete: { color: 0x888888, size: [gridSize, 0.1, gridSize] },
249
+ },
250
+ walls: {
251
+ brick: { color: 0xb55a44, size: [gridSize, 2.5, 0.2], solid: true },
252
+ concrete: { color: 0xc2c2c2, size: [gridSize, 2.5, 0.2], solid: true },
253
+ door: { color: 0x8B4513, size: [gridSize, 2.2, 0.2], solid: false },
254
+ },
255
+ objects: {
256
+ crate: { color: 0x966F33, size: [gridSize * 0.8, gridSize * 0.8, gridSize * 0.8], solid: true },
257
+ barrel: { color: 0x595959, size: [gridSize * 0.4, gridSize * 0.8, 0], geometry: 'cylinder', solid: true },
258
+ },
259
+ terrain: {
260
+ ground: { color: 0x9b7653, size: [gridSize, 0.1, gridSize], isScalable: true }
261
+ }
262
  };
 
263
 
264
+ const instancedMeshes = {};
 
 
 
 
 
 
 
 
265
 
266
  function init() {
267
  scene = new THREE.Scene();
268
  scene.background = new THREE.Color(0x1d2935);
269
  scene.fog = new THREE.Fog(0x1d2935, 50, 150);
270
 
271
+ camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);
272
+ camera.position.set(25, 30, 25);
 
 
 
273
 
274
  renderer = new THREE.WebGLRenderer({ antialias: true });
275
  renderer.setSize(window.innerWidth, window.innerHeight);
 
280
 
281
  orbitControls = new OrbitControls(camera, renderer.domElement);
282
  orbitControls.enableDamping = true;
283
+ orbitControls.maxPolarAngle = Math.PI / 2.1;
284
+ orbitControls.minDistance = 10;
285
+ orbitControls.maxDistance = 100;
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
+ const hemiLight = new THREE.HemisphereLight(0xB1E1FF, 0xB97A20, 0.8);
288
  scene.add(hemiLight);
289
 
290
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
291
  dirLight.position.set(-30, 50, -30);
292
  dirLight.castShadow = true;
293
  dirLight.shadow.mapSize.width = 2048;
 
298
  dirLight.shadow.camera.bottom = -50;
299
  scene.add(dirLight);
300
 
301
+ gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x444444);
302
  scene.add(gridHelper);
303
 
304
  const planeGeo = new THREE.PlaneGeometry(100, 100);
 
312
  initUI();
313
  initPlayer();
314
  initJoystick();
315
+ initInstancedMeshes();
316
 
317
  window.addEventListener('resize', onWindowResize);
318
  renderer.domElement.addEventListener('pointermove', onPointerMove);
319
  renderer.domElement.addEventListener('pointerdown', onPointerDown);
320
+ window.addEventListener('keydown', handleKeyDown);
321
  window.addEventListener('keyup', e => { keyStates[e.code] = false; });
322
  renderer.domElement.addEventListener('wheel', e => {
323
+ if(!isPlayMode) { e.preventDefault(); rotatePreview(e.deltaY > 0 ? 1 : -1); }
 
 
 
324
  }, { passive: false });
325
  renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
326
 
327
  animate();
328
  }
329
 
330
+ function getTexture(textureName) {
331
+ if (!textureName) return null;
332
+ if (textureCache[textureName]) return textureCache[textureName];
333
+
334
+ const textureUrl = `${HF_REPO_URL}pz_textures/${textureName}`;
335
+ const texture = textureLoader.load(textureUrl);
336
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
337
+ textureCache[textureName] = texture;
338
+ return texture;
339
+ }
340
+
341
+ function initInstancedMeshes() {
342
+ const MAX_COUNT = 10000;
343
+ for (const category in ASSETS) {
344
+ for (const type in ASSETS[category]) {
345
+ const id = `${category}-${type}`;
346
+ const asset = ASSETS[category][type];
347
+ let geometry;
348
+ if (asset.geometry === 'cylinder') {
349
+ geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
350
+ } else {
351
+ geometry = new THREE.BoxGeometry(...asset.size);
352
+ }
353
+ const material = new THREE.MeshStandardMaterial({ color: asset.color });
354
+ const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);
355
+ mesh.castShadow = true;
356
+ mesh.receiveShadow = true;
357
+ mesh.count = 0;
358
+ instancedMeshes[id] = { mesh, material, geometry };
359
+ scene.add(mesh);
360
+ }
361
+ }
362
+ }
363
+
364
  function initPlayer() {
365
+ const playerGeo = new THREE.CylinderGeometry(0.25, 0.25, 1.5, 16);
366
+ playerGeo.translate(0, 0.75, 0);
367
+ const playerMat = new THREE.MeshStandardMaterial({ color: 0x00aaff });
368
  player = new THREE.Mesh(playerGeo, playerMat);
369
+ player.castShadow = true;
370
  player.visible = false;
371
  scene.add(player);
372
  }
373
 
374
  function initUI() {
375
  const toolSelector = document.getElementById('tool-selector');
376
+ for (const category in ASSETS) {
377
+ for (const type in ASSETS[category]) {
378
+ const item = document.createElement('div');
379
+ item.className = 'tool-item';
380
+ item.textContent = `${category.slice(0,1).toUpperCase()}: ${type}`;
381
+ item.dataset.category = category;
382
+ item.dataset.type = type;
383
+ item.addEventListener('click', () => selectTool(category, type));
384
+ toolSelector.appendChild(item);
385
+ }
386
  }
387
+ selectTool('floors', 'grass');
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
  document.getElementById('save-project').addEventListener('click', saveProject);
390
  document.getElementById('load-project').addEventListener('click', loadProject);
 
392
  document.getElementById('clear-level').addEventListener('click', clearLevel);
393
  document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open'));
394
  document.getElementById('project-list').addEventListener('change', e => document.getElementById('project-name').value = e.target.value);
395
+ document.getElementById('upload-texture-btn').addEventListener('click', uploadTexture);
396
+ document.getElementById('blocker').addEventListener('click', () => {
397
+ if(isPlayMode) {
398
+ document.getElementById('blocker').style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  }
400
  });
401
  }
 
406
  const maxRadius = joystickContainer.clientWidth / 2;
407
 
408
  function onTouchStart(event) {
409
+ if (!isPlayMode) return;
410
  const touch = event.touches[0];
411
  joystick.active = true;
412
  joystick.center.set(touch.clientX, touch.clientY);
 
 
413
  }
414
  function onTouchMove(event) {
415
+ if (!isPlayMode || !joystick.active) return;
416
  event.preventDefault();
 
417
  const touch = event.touches[0];
418
  joystick.current.set(touch.clientX, touch.clientY);
419
  joystick.vector.copy(joystick.current).sub(joystick.center);
 
425
  joystick.vector.set(0, 0);
426
  joystickHandle.style.transform = 'translate(0, 0)';
427
  }
428
+ renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: false });
429
+ renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: false });
430
+ renderer.domElement.addEventListener('touchend', onTouchEnd, { passive: false });
431
  }
432
 
433
+ function selectTool(category, type) {
434
+ currentTool = { category, type };
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active'));
436
+ document.querySelector(`.tool-item[data-category="${category}"][data-type="${type}"]`).classList.add('active');
 
437
 
438
  if (previewMesh) scene.remove(previewMesh);
439
+ const asset = ASSETS[category][type];
440
  let geometry;
441
+ if (asset.geometry === 'cylinder') {
442
+ geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16);
443
  } else {
444
+ let w = asset.isScalable ? parseFloat(document.getElementById('terrain-width').value) : asset.size[0];
445
+ let d = asset.isScalable ? parseFloat(document.getElementById('terrain-depth').value) : asset.size[2];
446
+ geometry = new THREE.BoxGeometry(w, asset.size[1], d);
447
  }
448
+
449
+ const material = new THREE.MeshBasicMaterial({ color: asset.color, transparent: true, opacity: 0.6 });
450
  previewMesh = new THREE.Mesh(geometry, material);
451
  scene.add(previewMesh);
452
+ updateToolPropertiesPanel();
453
+ }
454
+
455
+ function updateToolPropertiesPanel() {
456
+ const asset = ASSETS[currentTool.category][currentTool.type];
457
+ document.getElementById('prop-terrain-size').style.display = asset.isScalable ? 'block' : 'none';
458
  }
459
 
460
  function onWindowResize() {
461
+ camera.aspect = window.innerWidth / window.innerHeight;
 
 
 
 
 
462
  camera.updateProjectionMatrix();
463
  renderer.setSize(window.innerWidth, window.innerHeight);
464
  }
465
 
466
  function onPointerMove(event) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  if (isPlayMode) return;
468
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
469
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
470
  raycaster.setFromCamera(mouse, camera);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  const intersects = raycaster.intersectObject(placementPlane);
472
  if (intersects.length > 0) {
473
  const point = intersects[0].point;
474
  const gridX = Math.round(point.x / gridSize);
475
  const gridZ = Math.round(point.z / gridSize);
476
+
477
+ const asset = ASSETS[currentTool.category][currentTool.type];
478
+ let yPos = asset.size[1] / 2;
479
+ if (currentTool.category === 'walls') yPos = asset.size[1] / 2;
480
+ previewMesh.position.set(gridX * gridSize, yPos, gridZ * gridSize);
481
  previewMesh.visible = true;
482
  } else {
483
  previewMesh.visible = false;
484
  }
485
  }
486
 
487
+ function handleKeyDown(event) {
488
+ keyStates[event.code] = true;
489
+ if (isPlayMode) {
490
+ if (event.code === 'Escape') togglePlayMode();
491
+ } else {
492
+ if (event.code === 'KeyR') rotatePreview(1);
493
+ }
494
  }
495
+
496
+ function onPointerDown(event) {
497
+ if (isPlayMode || !previewMesh.visible) return;
498
+ if (event.target !== renderer.domElement) return;
499
 
500
+ const isRemoving = event.shiftKey;
 
 
501
 
502
+ if (event.button === 2) { rotatePreview(1); return; }
503
+ if (event.button !== 0) return;
504
 
505
+ const pos = previewMesh.position;
506
+ const key = `${Math.round(pos.x)},${Math.round(pos.z)}`;
 
 
 
 
 
 
 
 
 
507
 
508
+ if (isRemoving) {
509
+ removeItemAt(pos);
510
+ } else {
511
+ addItem(pos, currentRotation);
512
+ }
513
+ updateLevelGeometry();
514
  }
515
 
516
+ function rotatePreview(direction) {
517
+ currentRotation = (currentRotation + (Math.PI / 2) * direction) % (Math.PI * 2);
518
+ previewMesh.rotation.y = currentRotation;
519
+ }
520
+
521
+ function addItem(pos, rotation) {
522
+ const { category, type } = currentTool;
523
+ const asset = ASSETS[category][type];
524
+
525
+ const itemData = {
526
  id: THREE.MathUtils.generateUUID(),
527
+ category,
528
+ type,
529
+ x: pos.x, y: pos.y, z: pos.z,
530
+ rot: rotation,
531
+ texture: document.getElementById('texture-select').value || null
532
  };
 
 
 
533
 
534
+ if (asset.isScalable) {
535
+ itemData.width = parseFloat(document.getElementById('terrain-width').value);
536
+ itemData.depth = parseFloat(document.getElementById('terrain-depth').value);
537
+ }
538
+
539
+ removeItemAt(pos);
540
+ levelData.items.push(itemData);
541
  }
542
 
543
+ function removeItemAt(pos) {
544
+ levelData.items = levelData.items.filter(item => {
545
+ return !(item.x === pos.x && item.z === pos.z);
546
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  }
548
 
549
+ function updateLevelGeometry() {
550
+ const dummy = new THREE.Object3D();
551
  collisionObjects = [];
 
552
 
553
+ Object.values(instancedMeshes).forEach(im => {
554
+ im.mesh.count = 0;
555
+ im.mesh.instanceMatrix.needsUpdate = true;
556
+ });
557
+ const materialCache = {};
558
+
559
+ for (const item of levelData.items) {
560
+ const asset = ASSETS[item.category]?.[item.type];
561
+ if (!asset) continue;
562
+
563
+ const id = `${item.category}-${item.type}`;
564
+ const instanced = instancedMeshes[id];
565
+ if (!instanced) continue;
566
+
567
+ dummy.position.set(item.x, item.y, item.z);
568
+ dummy.rotation.set(0, item.rot, 0);
569
+
570
+ let w = item.width || asset.size[0];
571
+ let h = asset.size[1];
572
+ let d = item.depth || asset.size[2];
573
+ dummy.scale.set(w / asset.size[0], 1, d / asset.size[2]);
574
+
575
+ dummy.updateMatrix();
576
 
577
+ const matrixIndex = instanced.mesh.count;
578
+ instanced.mesh.setMatrixAt(matrixIndex, dummy.matrix);
579
+
580
+ const matKey = item.texture || 'default';
581
+ if (!materialCache[matKey]) {
582
+ materialCache[matKey] = instanced.material.clone();
583
+ if(item.texture) {
584
+ const texture = getTexture(item.texture);
585
+ materialCache[matKey].map = texture;
586
+ texture.repeat.set(w, d);
587
+ texture.needsUpdate = true;
588
+ }
589
+ }
590
+
591
+ if (!instanced.mesh.material.isArray) {
592
+ instanced.mesh.material = [];
593
+ }
594
+
595
+ const matIndex = instanced.mesh.material.findIndex(m => m.uuid === materialCache[matKey].uuid);
596
+ if (matIndex === -1) {
597
+ instanced.mesh.material.push(materialCache[matKey]);
598
+ instanced.mesh.geometry.addGroup(matrixIndex, 1, instanced.mesh.material.length - 1);
599
  } else {
600
+ instanced.mesh.geometry.addGroup(matrixIndex, 1, matIndex);
601
  }
602
+
603
+ instanced.mesh.count++;
604
+
605
+ if (asset.solid) {
606
+ const box = new THREE.Box3();
607
+ const collisionGeo = new THREE.BoxGeometry(w, h, d);
608
+ box.copy(collisionGeo.boundingBox).applyMatrix4(dummy.matrix);
 
 
 
 
609
  collisionObjects.push(box);
610
  }
611
+ }
612
+ Object.values(instancedMeshes).forEach(im => im.mesh.instanceMatrix.needsUpdate = true);
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  }
614
 
615
  function clearLevel() {
616
  if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return;
617
+ levelData.items = [];
618
+ updateLevelGeometry();
 
 
 
619
  }
620
 
621
  function togglePlayMode() {
 
626
 
627
  if (isPlayMode) {
628
  uiContainer.style.display = 'none';
629
+ gridHelper.visible = false;
630
  previewMesh.visible = false;
 
631
  orbitControls.enabled = false;
632
  player.visible = true;
633
+ player.position.set(0, 1, 0);
634
  blocker.style.display = 'flex';
635
  joystickContainer.style.display = 'block';
 
 
 
 
 
 
 
636
  } else {
 
637
  uiContainer.style.display = 'flex';
638
+ gridHelper.visible = true;
639
+ previewMesh.visible = true;
640
  orbitControls.enabled = true;
641
  player.visible = false;
642
  blocker.style.display = 'none';
643
  joystickContainer.style.display = 'none';
644
+ camera.position.set(25, 30, 25);
645
+ orbitControls.target.set(0, 0, 0);
 
 
646
  }
647
  }
648
 
 
680
  const result = await response.json();
681
  if (!response.ok) throw new Error('Проект не найден или ошибка загрузки.');
682
 
683
+ levelData.items = [];
684
  Object.assign(levelData, result.data);
685
+ updateLevelGeometry();
686
  document.getElementById('project-name').value = projectName;
687
 
688
  } catch (error) {
 
692
  }
693
  }
694
 
695
+ async function uploadTexture() {
696
+ const fileInput = document.getElementById('texture-file-input');
697
+ const file = fileInput.files[0];
698
+ if (!file) { alert('Пожалуйста, выберите файл текстуры.'); return; }
699
 
700
+ const formData = new FormData();
701
+ formData.append('texture', file);
702
+ showSpinner(true);
703
+ try {
704
+ const response = await fetch('/api/texture', { method: 'POST', body: formData });
705
+ const result = await response.json();
706
+ if (!response.ok) throw new Error(result.error);
707
+
708
+ alert(`Текстура ${result.filename} успешно загружена.`);
709
+ const textureSelect = document.getElementById('texture-select');
710
+ const newOption = document.createElement('option');
711
+ newOption.value = newOption.textContent = result.filename;
712
+ textureSelect.appendChild(newOption);
713
+ fileInput.value = '';
714
+ } catch (error) {
715
+ alert(`Ошибка загрузки текстуры: ${error.message}`);
716
+ } finally {
717
+ showSpinner(false);
718
+ }
719
+ }
720
+
721
+ const moveDirection = new THREE.Vector3();
722
  function updatePlayer(deltaTime) {
 
 
723
  const speed = playerSpeed * deltaTime;
 
 
 
 
 
724
  moveDirection.set(0,0,0);
725
+ if (keyStates['KeyW'] || keyStates['ArrowUp']) moveDirection.z -= 1;
726
+ if (keyStates['KeyS'] || keyStates['ArrowDown']) moveDirection.z += 1;
727
+ if (keyStates['KeyA'] || keyStates['ArrowLeft']) moveDirection.x -= 1;
728
+ if (keyStates['KeyD'] || keyStates['ArrowRight']) moveDirection.x += 1;
 
 
729
 
730
  if (joystick.active) {
731
  const maxRadius = 60;
732
+ moveDirection.x += joystick.vector.x / maxRadius;
733
+ moveDirection.z += joystick.vector.y / maxRadius;
 
734
  }
735
 
736
+ if (moveDirection.lengthSq() === 0) return;
737
+ moveDirection.normalize().multiplyScalar(speed);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
 
739
+ const playerBox = new THREE.Box3().setFromObject(player);
740
+
741
+ const intendedXPos = player.position.clone().add(new THREE.Vector3(moveDirection.x, 0, 0));
742
+ const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0));
743
+ if (!collisionObjects.some(box => playerBoxX.intersectsBox(box))) {
744
+ player.position.x = intendedXPos.x;
 
 
 
 
745
  }
746
 
747
+ const intendedZPos = player.position.clone().add(new THREE.Vector3(0, 0, moveDirection.z));
748
+ const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z));
749
+ if (!collisionObjects.some(box => playerBoxZ.intersectsBox(box))) {
750
+ player.position.z = intendedZPos.z;
751
  }
752
 
753
+ camera.position.x = player.position.x + 15;
754
+ camera.position.z = player.position.z + 15;
755
+ camera.position.y = 20;
756
+ camera.lookAt(player.position);
 
 
 
 
757
  }
758
 
759
  function showSpinner(show) {
 
764
  requestAnimationFrame(animate);
765
  const deltaTime = clock.getDelta();
766
 
767
+ if (isPlayMode && document.getElementById('blocker').style.display === 'none') {
768
  updatePlayer(deltaTime);
769
+ } else if (!isPlayMode) {
 
 
770
  orbitControls.update();
 
771
  }
772
+ renderer.render(scene, camera);
773
  }
774
 
775
  init();
 
780
 
781
  @app.route('/')
782
  def editor():
783
+ projects = [p.replace('.json', '') for p in list_files_from_hf('pz_projects/')]
784
+ textures = list_files_from_hf('pz_textures/')
785
+ return render_template_string(EDITOR_TEMPLATE, projects=projects, textures=textures, REPO_ID=REPO_ID)
786
 
787
  @app.route('/api/project', methods=['POST'])
788
  def save_project_api():
 
792
  return jsonify({"error": "Project name is required"}), 400
793
 
794
  local_filename = f"{uuid4().hex}.json"
795
+ temp_path = os.path.join(app.config['UPLOAD_FOLDER'], local_filename)
796
+ with open(temp_path, 'w', encoding='utf-8') as f:
797
  json.dump(data, f)
798
 
799
+ success = upload_to_hf(
800
+ temp_path,
801
+ f"pz_projects/{project_name}.json",
802
+ f"Save PZ project {project_name}"
803
+ )
804
+
805
+ if os.path.exists(temp_path):
806
+ os.remove(temp_path)
807
 
808
  if success:
809
  return jsonify({"message": "Project saved successfully"}), 201
 
818
  else:
819
  return jsonify({"error": "Project not found or failed to download"}), 404
820
 
821
+ @app.route('/api/texture', methods=['POST'])
822
+ def upload_texture_api():
823
+ if 'texture' not in request.files:
824
+ return jsonify({"error": "No texture file part"}), 400
825
+ file = request.files['texture']
826
+ if file.filename == '':
827
+ return jsonify({"error": "No selected file"}), 400
828
+ if file:
829
+ filename = secure_filename(file.filename)
830
+ temp_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
831
+ file.save(temp_path)
832
+
833
+ success = upload_to_hf(
834
+ temp_path,
835
+ f"pz_textures/{filename}",
836
+ f"Upload texture {filename}"
 
837
  )
838
+
839
+ if os.path.exists(temp_path):
840
+ os.remove(temp_path)
841
+
842
+ if success:
843
+ return jsonify({"message": "Texture uploaded", "filename": filename}), 201
844
+ else:
845
+ return jsonify({"error": "Failed to upload texture"}), 500
846
+ return jsonify({"error": "File error"}), 500
847
+
848
 
849
  if __name__ == '__main__':
850
  port = int(os.environ.get('PORT', 7860))