| import os |
| import json |
| import time |
| from datetime import datetime |
| from uuid import uuid4 |
|
|
| from flask import Flask, render_template_string, request, jsonify |
| from huggingface_hub import HfApi, hf_hub_download |
| from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
|
|
| app = Flask(__name__) |
| app.secret_key = 'level_designer_secret_key_zomboid_5678' |
|
|
| REPO_ID = "Kgshop/Testai" |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") |
|
|
| def upload_project_to_hf(local_path, project_name): |
| if not HF_TOKEN_WRITE: |
| return False |
| try: |
| api = HfApi() |
| api.upload_file( |
| path_or_fileobj=local_path, |
| path_in_repo=f"pz_projects/{project_name}.json", |
| repo_id=REPO_ID, |
| repo_type="dataset", |
| token=HF_TOKEN_WRITE, |
| commit_message=f"Save PZ project {project_name} at {datetime.now()}" |
| ) |
| return True |
| except Exception as e: |
| print(f"Error uploading {project_name} to HF: {e}") |
| return False |
|
|
| def download_project_from_hf(project_name): |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE |
| if not token_to_use: |
| return None |
| try: |
| local_path = hf_hub_download( |
| repo_id=REPO_ID, |
| filename=f"pz_projects/{project_name}.json", |
| repo_type="dataset", |
| token=token_to_use, |
| local_dir=".", |
| local_dir_use_symlinks=False, |
| force_download=True |
| ) |
| with open(local_path, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| if os.path.exists(local_path): |
| os.remove(local_path) |
| return data |
| except (HfHubHTTPError, RepositoryNotFoundError) as e: |
| return None |
| except Exception as e: |
| return None |
|
|
| def list_projects_from_hf(): |
| token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE |
| if not token_to_use: |
| return [] |
| try: |
| api = HfApi() |
| repo_info = api.list_repo_files(repo_id=REPO_ID, repo_type="dataset", token=token_to_use) |
| project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('pz_projects/') and f.endswith('.json')] |
| return sorted(project_files) |
| except Exception: |
| return [] |
|
|
| EDITOR_TEMPLATE = ''' |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
| <title>PZ Style Constructor</title> |
| <style> |
| body { margin: 0; overflow: hidden; background-color: #1a1a1a; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } |
| canvas { display: block; } |
| #ui-container { position: absolute; top: 0; left: 0; height: 100%; z-index: 10; pointer-events: none; display: flex; } |
| #ui-panel { |
| background: rgba(10, 10, 10, 0.85); |
| backdrop-filter: blur(5px); |
| padding: 15px; |
| border-right: 1px solid #444; |
| width: 320px; |
| height: 100%; |
| overflow-y: auto; |
| box-sizing: border-box; |
| transition: transform 0.3s ease-in-out; |
| transform: translateX(0); |
| pointer-events: auto; |
| } |
| .ui-group { margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid #333; } |
| .ui-group:last-child { border-bottom: none; } |
| h3 { margin-top: 0; font-size: 1.2em; color: #00aaff; border-bottom: 1px solid #00aaff; padding-bottom: 5px; margin-bottom: 10px; } |
| label { display: block; margin-bottom: 5px; font-size: 0.9em; } |
| input[type="text"], input[type="number"], select { |
| width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; margin-bottom: 5px; |
| } |
| button { |
| 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; |
| } |
| button:hover { background: #0099ff; } |
| .play-button { background: #22aa22; } |
| .play-button:hover { background: #33cc33; } |
| .danger-button { background: #c00; } |
| .danger-button:hover { background: #e00; } |
| .tool-selector { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; } |
| .tool-item { |
| padding: 10px; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; text-align: center; cursor: pointer; |
| transition: all 0.2s; font-size: 0.9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| } |
| .tool-item:hover { background: #3a3a3a; border-color: #666; } |
| .tool-item.active { background: #0077cc; border-color: #00aaff; } |
| .draw-mode-selector { display: flex; gap: 10px; margin-bottom: 10px; } |
| .draw-mode-selector button { margin-top: 0; } |
| #loading-spinner { |
| position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); |
| border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%; |
| width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 10001; |
| } |
| #blocker { position: fixed; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none; top:0; left:0; z-index: 9999; } |
| #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; } |
| #burger-menu { |
| position: absolute; top: 15px; left: 15px; z-index: 20; display: none; width: 30px; height: 22px; cursor: pointer; pointer-events: auto; |
| } |
| #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; } |
| #burger-menu span:nth-child(1) { top: 0px; } #burger-menu span:nth-child(2) { top: 9px; } #burger-menu span:nth-child(3) { top: 18px; } |
| #joystick-container { |
| position: absolute; bottom: 30px; left: 30px; width: 120px; height: 120px; background: rgba(128, 128, 128, 0.3); border-radius: 50%; |
| display: none; z-index: 100; pointer-events: auto; user-select: none; |
| } |
| #joystick-handle { position: absolute; top: 30px; left: 30px; width: 60px; height: 60px; background: rgba(255, 255, 255, 0.5); border-radius: 50%; } |
| |
| #building-modal { |
| position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); |
| background: rgba(20, 20, 20, 0.95); |
| padding: 20px; border-radius: 8px; z-index: 10000; |
| border: 1px solid #555; box-shadow: 0 0 20px rgba(0,0,0,0.5); |
| max-width: 90vw; max-height: 90vh; overflow-y: auto; |
| } |
| #building-preview-container { position: relative; cursor: crosshair; margin-bottom: 15px; } |
| #building-preview-img { max-width: 100%; max-height: 60vh; display: block; } |
| #building-entrance-canvas { position: absolute; top: 0; left: 0; pointer-events: none; } |
| |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } |
| @media (max-width: 800px) { |
| #ui-panel { transform: translateX(-100%); padding-top: 60px; } |
| #ui-panel.open { transform: translateX(0); } |
| #burger-menu { display: block; } |
| #joystick-container { display: none; } |
| #joystick-handle { width: 50px; height: 50px; top: 25px; left: 25px; } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="ui-container"> |
| <div id="burger-menu"><span></span><span></span><span></span></div> |
| <div id="ui-panel"> |
| <div class="ui-group"> |
| <h3>Проект</h3> |
| <select id="project-list"> |
| <option value="">Выберите проект...</option> |
| {% for project in projects %} |
| <option value="{{ project }}">{{ project }}</option> |
| {% endfor %} |
| </select> |
| <button id="load-project">Загрузить</button> |
| <hr style="border-color: #333; margin: 15px 0;"> |
| <input type="text" id="project-name" placeholder="new-level-01"> |
| <button id="save-project">Сохранить</button> |
| </div> |
| <div class="ui-group"> |
| <h3>Режим</h3> |
| <button id="play-mode-toggle" class="play-button">Играть</button> |
| </div> |
| <div class="ui-group"> |
| <h3>Инструменты</h3> |
| <label>Режим расстановки:</label> |
| <div class="draw-mode-selector"> |
| <button id="draw-mode-single" class="tool-item active">Один</button> |
| <button id="draw-mode-area" class="tool-item">Область</button> |
| </div> |
| <div id="tool-selector" class="tool-selector"></div> |
| <div id="custom-building-options" style="display:none; margin-top:10px;"> |
| <label>Ширина:</label> |
| <input type="number" id="building-width-input" value="10" step="0.5"> |
| </div> |
| <p style="font-size: 0.8em; color: #888; margin-top: 10px;"> |
| ЛКМ: Разместить / Начать область<br> |
| Отпустить ЛКМ: Закончить область<br> |
| ПКМ: Вращать<br> |
| Shift + ЛКМ: Удалить<br> |
| Колесо мыши: Вращать |
| </p> |
| <button id="clear-level" class="danger-button">Очистить уровень</button> |
| </div> |
| <div class="ui-group"> |
| <h3>Кастомные здания</h3> |
| <button id="add-building-btn">Добавить здание</button> |
| <input type="file" id="building-image-input" accept="image/png" style="display: none;"> |
| <div id="custom-tools-selector" class="tool-selector" style="margin-top: 10px;"></div> |
| </div> |
| </div> |
| </div> |
| |
| <div id="building-modal" style="display: none;"> |
| <h3>Новое здание</h3> |
| <p style="font-size: 0.9em; color: #ccc;">Кликните на изображение, чтобы указать точку входа.</p> |
| <div id="building-preview-container"> |
| <img id="building-preview-img" alt="Building Preview"> |
| <canvas id="building-entrance-canvas"></canvas> |
| </div> |
| <label style="display: flex; align-items: center; gap: 10px;"> |
| <input type="checkbox" id="building-solid-checkbox" checked> |
| <span>Плотное здание (с коллзией)</span> |
| </label> |
| <button id="save-new-building">Сохранить и выбрать</button> |
| <button id="cancel-new-building" class="danger-button" style="background-color: #555;">Отмена</button> |
| </div> |
| |
| <div id="blocker"><div id="instructions"><p style="font-size:36px">Нажмите, чтобы играть</p><p>Движение: WASD / Джойстик</p><p>Нажмите ESC для выхода</p></div></div> |
| <div id="loading-spinner"></div> |
| <div id="joystick-container"><div id="joystick-handle"></div></div> |
| |
| <script type="importmap"> |
| { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } } |
| </script> |
| <script type="module"> |
| import * as THREE from 'three'; |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
| |
| let scene, camera, renderer, orbitControls; |
| let raycaster, mouse, placementPlane, gridHelper, previewMesh, selectionBox; |
| |
| let isPlayMode = false; |
| let player, playerVelocity = new THREE.Vector3(); |
| const playerSpeed = 5.0; |
| const keyStates = {}; |
| const clock = new THREE.Clock(); |
| let collisionBBoxes = []; |
| |
| let currentTool = { category: 'floors', type: 'grass' }; |
| let currentRotation = 0; |
| const gridSize = 1; |
| let levelData = { floors: {}, walls: {}, objects: {}, buildingDefinitions: {}, buildingInstances: [] }; |
| let drawMode = 'single'; |
| let isDrawingArea = false; |
| let areaStartPoint = new THREE.Vector3(); |
| |
| const joystick = { active: false, center: new THREE.Vector2(), current: new THREE.Vector2(), vector: new THREE.Vector2() }; |
| |
| const TEXTURE_BASE_URL = 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/'; |
| |
| const ASSETS = { |
| floors: { |
| grass: { texture: TEXTURE_BASE_URL + 'terrain/grasslight-big.jpg', size: [gridSize, 0.1, gridSize], solid: false }, |
| concrete: { texture: TEXTURE_BASE_URL + 'concrete.jpg', size: [gridSize, 0.1, gridSize], solid: false }, |
| wood: { texture: TEXTURE_BASE_URL + 'hardwood2_diffuse.jpg', size: [gridSize, 0.1, gridSize], solid: false }, |
| dirt: { texture: TEXTURE_BASE_URL + 'terrain/dirt_grass.jpg', size: [gridSize, 0.1, gridSize], solid: false } |
| }, |
| walls: { |
| brick: { texture: TEXTURE_BASE_URL + 'brick_diffuse.jpg', size: [gridSize, 2.5, 0.2], solid: true }, |
| concrete_wall: { texture: TEXTURE_BASE_URL + 'concrete.jpg', size: [gridSize, 2.5, 0.2], solid: true } |
| }, |
| objects: { |
| crate: { texture: TEXTURE_BASE_URL + 'crate.gif', size: [gridSize, gridSize, gridSize], solid: true }, |
| barrel: { texture: TEXTURE_BASE_URL + 'metal.jpg', size: [gridSize * 0.4, gridSize * 0.8, 0], geometry: 'cylinder', solid: true }, |
| bush: { texture: TEXTURE_BASE_URL + 'terrain/grasslight-big.jpg', size: [gridSize * 0.8, gridSize * 0.8, 0], geometry: 'cylinder', solid: false } |
| } |
| }; |
| |
| const instancedMeshes = {}; |
| const loadedTextures = {}; |
| const customBuildingMeshes = []; |
| let entranceHelper; |
| let currentEntranceCoords = {x: 0.5, y: 0.95}; |
| |
| function init() { |
| showSpinner(true); |
| const loadingManager = new THREE.LoadingManager(); |
| const textureLoader = new THREE.TextureLoader(loadingManager); |
| |
| for (const category in ASSETS) { |
| loadedTextures[category] = {}; |
| for (const type in ASSETS[category]) { |
| const asset = ASSETS[category][type]; |
| if (asset.texture) { |
| loadedTextures[category][type] = textureLoader.load(asset.texture); |
| loadedTextures[category][type].wrapS = THREE.RepeatWrapping; |
| loadedTextures[category][type].wrapT = THREE.RepeatWrapping; |
| } |
| } |
| } |
| |
| loadingManager.onLoad = () => { |
| setupScene(); |
| animate(); |
| showSpinner(false); |
| }; |
| } |
| |
| function setupScene() { |
| scene = new THREE.Scene(); |
| scene.background = new THREE.Color(0x334455); |
| scene.fog = new THREE.Fog(0x334455, 50, 150); |
| |
| const aspect = window.innerWidth / window.innerHeight; |
| const d = 25; |
| camera = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 1, 2000); |
| camera.position.set(50, 50, 50); |
| camera.lookAt(scene.position); |
| |
| renderer = new THREE.WebGLRenderer({ antialias: true }); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| renderer.setPixelRatio(window.devicePixelRatio); |
| renderer.shadowMap.enabled = true; |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
| document.body.appendChild(renderer.domElement); |
| |
| orbitControls = new OrbitControls(camera, renderer.domElement); |
| orbitControls.enableDamping = true; |
| orbitControls.minZoom = 0.5; |
| orbitControls.maxZoom = 4; |
| |
| const hemiLight = new THREE.HemisphereLight(0xB1E1FF, 0x494949, 0.8); |
| scene.add(hemiLight); |
| |
| const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); |
| dirLight.position.set(-30, 50, -30); |
| dirLight.castShadow = true; |
| dirLight.shadow.mapSize.width = 2048; |
| dirLight.shadow.mapSize.height = 2048; |
| const shadowSize = 50; |
| dirLight.shadow.camera.left = -shadowSize; |
| dirLight.shadow.camera.right = shadowSize; |
| dirLight.shadow.camera.top = shadowSize; |
| dirLight.shadow.camera.bottom = -shadowSize; |
| scene.add(dirLight); |
| |
| gridHelper = new THREE.GridHelper(100, 100, 0x556677, 0x556677); |
| scene.add(gridHelper); |
| |
| const planeGeo = new THREE.PlaneGeometry(1000, 1000); |
| planeGeo.rotateX(-Math.PI / 2); |
| placementPlane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({ visible: false })); |
| scene.add(placementPlane); |
| |
| raycaster = new THREE.Raycaster(); |
| mouse = new THREE.Vector2(); |
| |
| initUI(); |
| initPlayer(); |
| initJoystick(); |
| initInstancedMeshes(); |
| |
| selectionBox = new THREE.LineSegments( |
| new THREE.EdgesGeometry(new THREE.BoxGeometry(1,1,1)), |
| new THREE.LineBasicMaterial({ color: 0xffff00, linewidth: 2 }) |
| ); |
| selectionBox.visible = false; |
| scene.add(selectionBox); |
| |
| const spriteMaterial = new THREE.SpriteMaterial({ color: 0x00ff00, transparent: true, opacity: 0.7, depthTest: false }); |
| entranceHelper = new THREE.Sprite(spriteMaterial); |
| entranceHelper.scale.set(1.5, 3, 1); |
| entranceHelper.visible = false; |
| scene.add(entranceHelper); |
| |
| window.addEventListener('resize', onWindowResize); |
| renderer.domElement.addEventListener('pointermove', onPointerMove); |
| renderer.domElement.addEventListener('pointerdown', onPointerDown); |
| renderer.domElement.addEventListener('pointerup', onPointerUp); |
| window.addEventListener('keydown', e => { keyStates[e.code] = true; handleKeyDown(e); }); |
| window.addEventListener('keyup', e => { keyStates[e.code] = false; }); |
| renderer.domElement.addEventListener('wheel', e => { |
| if(!isPlayMode) { |
| rotatePreview(e.deltaY > 0 ? 1 : -1); |
| } |
| }, { passive: false }); |
| renderer.domElement.addEventListener('contextmenu', e => e.preventDefault()); |
| document.getElementById('blocker').addEventListener('click', enterPlayMode); |
| } |
| |
| function initInstancedMeshes() { |
| const MAX_COUNT = 10000; |
| for (const category in ASSETS) { |
| instancedMeshes[category] = {}; |
| for (const type in ASSETS[category]) { |
| const asset = ASSETS[category][type]; |
| let geometry; |
| if(asset.geometry === 'cylinder') { |
| geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16); |
| } else { |
| geometry = new THREE.BoxGeometry(...asset.size); |
| } |
| const material = new THREE.MeshStandardMaterial({ map: loadedTextures[category][type] }); |
| const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT); |
| mesh.castShadow = true; |
| mesh.receiveShadow = true; |
| mesh.count = 0; |
| instancedMeshes[category][type] = mesh; |
| scene.add(mesh); |
| } |
| } |
| } |
| |
| function initPlayer() { |
| const playerGeo = new THREE.CylinderGeometry(0.3, 0.3, 1.8, 16); |
| playerGeo.translate(0, 0.9, 0); |
| const playerMat = new THREE.MeshStandardMaterial({ color: 0x00aaff }); |
| player = new THREE.Mesh(playerGeo, playerMat); |
| player.castShadow = true; |
| player.visible = false; |
| scene.add(player); |
| } |
| |
| function initUI() { |
| const toolSelector = document.getElementById('tool-selector'); |
| toolSelector.innerHTML = ''; |
| for (const category in ASSETS) { |
| for (const type in ASSETS[category]) { |
| const item = document.createElement('div'); |
| item.className = 'tool-item'; |
| item.textContent = `${category.slice(0,1).toUpperCase()}: ${type}`; |
| item.dataset.category = category; |
| item.dataset.type = type; |
| item.addEventListener('click', () => selectTool(category, type)); |
| toolSelector.appendChild(item); |
| } |
| } |
| selectTool('floors', 'grass'); |
| |
| document.getElementById('save-project').addEventListener('click', saveProject); |
| document.getElementById('load-project').addEventListener('click', loadProject); |
| document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode); |
| document.getElementById('clear-level').addEventListener('click', clearLevel); |
| document.getElementById('burger-menu').addEventListener('click', () => document.getElementById('ui-panel').classList.toggle('open')); |
| document.getElementById('project-list').addEventListener('change', e => document.getElementById('project-name').value = e.target.value); |
| |
| document.getElementById('draw-mode-single').addEventListener('click', () => setDrawMode('single')); |
| document.getElementById('draw-mode-area').addEventListener('click', () => setDrawMode('area')); |
| |
| document.getElementById('add-building-btn').addEventListener('click', () => document.getElementById('building-image-input').click()); |
| document.getElementById('building-image-input').addEventListener('change', handleImageUpload); |
| document.getElementById('save-new-building').addEventListener('click', saveNewBuildingDefinition); |
| document.getElementById('cancel-new-building').addEventListener('click', () => { |
| document.getElementById('building-modal').style.display = 'none'; |
| document.getElementById('blocker').style.display = 'none'; |
| }); |
| document.getElementById('building-preview-container').addEventListener('click', setEntrancePoint); |
| document.getElementById('building-width-input').addEventListener('input', updatePreviewMesh); |
| } |
| |
| function handleImageUpload(event) { |
| const file = event.target.files[0]; |
| if (!file) return; |
| |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| const modal = document.getElementById('building-modal'); |
| const img = document.getElementById('building-preview-img'); |
| img.onload = () => { |
| const canvas = document.getElementById('building-entrance-canvas'); |
| canvas.width = img.clientWidth; |
| canvas.height = img.clientHeight; |
| currentEntranceCoords = {x: 0.5, y: 0.95}; |
| drawEntranceMarker(); |
| }; |
| img.src = e.target.result; |
| modal.style.display = 'block'; |
| document.getElementById('blocker').style.display = 'block'; |
| }; |
| reader.readAsDataURL(file); |
| event.target.value = ''; |
| } |
| |
| function setEntrancePoint(event) { |
| const rect = event.target.getBoundingClientRect(); |
| const x = event.clientX - rect.left; |
| const y = event.clientY - rect.top; |
| currentEntranceCoords = { x: x / rect.width, y: y / rect.height }; |
| drawEntranceMarker(); |
| } |
| |
| function drawEntranceMarker() { |
| const canvas = document.getElementById('building-entrance-canvas'); |
| const ctx = canvas.getContext('2d'); |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| const x = currentEntranceCoords.x * canvas.width; |
| const y = currentEntranceCoords.y * canvas.height; |
| ctx.beginPath(); |
| ctx.arc(x, y, 5, 0, 2 * Math.PI, false); |
| ctx.fillStyle = 'rgba(0, 255, 0, 0.8)'; |
| ctx.fill(); |
| ctx.lineWidth = 2; |
| ctx.strokeStyle = '#ffffff'; |
| ctx.stroke(); |
| } |
| |
| function saveNewBuildingDefinition() { |
| const img = document.getElementById('building-preview-img'); |
| const def = { |
| src: img.src, |
| isSolid: document.getElementById('building-solid-checkbox').checked, |
| entrance: currentEntranceCoords, |
| aspectRatio: img.naturalHeight / img.naturalWidth, |
| }; |
| const defId = `building_${Date.now()}`; |
| levelData.buildingDefinitions[defId] = def; |
| |
| document.getElementById('building-modal').style.display = 'none'; |
| document.getElementById('blocker').style.display = 'none'; |
| |
| updateCustomToolsUI(); |
| selectTool('custom', defId); |
| } |
| |
| function updateCustomToolsUI() { |
| const selector = document.getElementById('custom-tools-selector'); |
| selector.innerHTML = ''; |
| for (const defId in levelData.buildingDefinitions) { |
| const def = levelData.buildingDefinitions[defId]; |
| const item = document.createElement('div'); |
| item.className = 'tool-item'; |
| item.dataset.category = 'custom'; |
| item.dataset.type = defId; |
| item.title = defId; |
| |
| const img = document.createElement('img'); |
| img.src = def.src; |
| img.style.width = '100%'; |
| img.style.height = '40px'; |
| img.style.objectFit = 'cover'; |
| img.style.pointerEvents = 'none'; |
| |
| item.appendChild(img); |
| item.addEventListener('click', () => selectTool('custom', defId)); |
| selector.appendChild(item); |
| } |
| } |
| |
| function setDrawMode(mode) { |
| drawMode = mode; |
| document.getElementById('draw-mode-single').classList.toggle('active', mode === 'single'); |
| document.getElementById('draw-mode-area').classList.toggle('active', mode === 'area'); |
| document.getElementById('custom-building-options').style.display = 'none'; |
| } |
| |
| function initJoystick() { |
| const joystickContainer = document.getElementById('joystick-container'); |
| const joystickHandle = document.getElementById('joystick-handle'); |
| if(!joystickContainer) return; |
| const maxRadius = joystickContainer.clientWidth / 2; |
| function onTouchStart(event) { |
| if(!isPlayMode) return; |
| const touch = event.touches[0]; |
| joystick.active = true; |
| joystick.center.set(touch.clientX, touch.clientY); |
| } |
| function onTouchMove(event) { |
| if (!joystick.active || !isPlayMode) return; |
| const touch = event.touches[0]; |
| joystick.current.set(touch.clientX, touch.clientY); |
| joystick.vector.copy(joystick.current).sub(joystick.center); |
| if (joystick.vector.length() > maxRadius) joystick.vector.setLength(maxRadius); |
| joystickHandle.style.transform = `translate(${joystick.vector.x}px, ${joystick.vector.y}px)`; |
| } |
| function onTouchEnd() { |
| joystick.active = false; |
| joystick.vector.set(0, 0); |
| joystickHandle.style.transform = 'translate(0, 0)'; |
| } |
| renderer.domElement.addEventListener('touchstart', onTouchStart, { passive: true }); |
| renderer.domElement.addEventListener('touchmove', onTouchMove, { passive: true }); |
| renderer.domElement.addEventListener('touchend', onTouchEnd); |
| } |
| |
| function selectTool(category, type) { |
| currentTool = { category, type }; |
| document.querySelectorAll('.tool-item').forEach(el => el.classList.remove('active')); |
| const activeEl = document.querySelector(`.tool-item[data-category="${category}"][data-type="${type}"]`); |
| if (activeEl) activeEl.classList.add('active'); |
| |
| document.getElementById('custom-building-options').style.display = category === 'custom' ? 'block' : 'none'; |
| document.getElementById('draw-mode-area').style.display = category === 'custom' ? 'none' : 'flex'; |
| if (category === 'custom') setDrawMode('single'); |
| |
| updatePreviewMesh(); |
| } |
| |
| function updatePreviewMesh() { |
| if (previewMesh) { |
| scene.remove(previewMesh); |
| previewMesh.geometry.dispose(); |
| if(previewMesh.material.map) previewMesh.material.map.dispose(); |
| previewMesh.material.dispose(); |
| } |
| |
| let geometry, material; |
| if (currentTool.category === 'custom') { |
| const def = levelData.buildingDefinitions[currentTool.type]; |
| if (!def) return; |
| const width = parseFloat(document.getElementById('building-width-input').value) || 10; |
| const height = width * def.aspectRatio; |
| |
| geometry = new THREE.PlaneGeometry(width, height); |
| const texture = new THREE.TextureLoader().load(def.src); |
| material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.7, side: THREE.DoubleSide }); |
| previewMesh = new THREE.Mesh(geometry, material); |
| previewMesh.userData.isBuilding = true; |
| previewMesh.userData.size = {x: width, y: height, z: 0.2}; |
| } else { |
| const asset = ASSETS[currentTool.category][currentTool.type]; |
| if(asset.geometry === 'cylinder') { |
| geometry = new THREE.CylinderGeometry(asset.size[0], asset.size[0], asset.size[1], 16); |
| } else { |
| geometry = new THREE.BoxGeometry(...asset.size); |
| } |
| material = new THREE.MeshStandardMaterial({ map: loadedTextures[currentTool.category][currentTool.type], transparent: true, opacity: 0.6 }); |
| previewMesh = new THREE.Mesh(geometry, material); |
| previewMesh.userData.isBuilding = false; |
| } |
| previewMesh.rotation.y = currentRotation; |
| scene.add(previewMesh); |
| } |
| |
| function onWindowResize() { |
| const aspect = window.innerWidth / window.innerHeight; |
| const d = 25; |
| camera.left = -d * aspect; |
| camera.right = d * aspect; |
| camera.top = d; |
| camera.bottom = -d; |
| camera.updateProjectionMatrix(); |
| renderer.setSize(window.innerWidth, window.innerHeight); |
| } |
| |
| function onPointerMove(event) { |
| if (isPlayMode) return; |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; |
| raycaster.setFromCamera(mouse, camera); |
| const intersects = raycaster.intersectObject(placementPlane); |
| if (intersects.length > 0 && previewMesh) { |
| const point = intersects[0].point; |
| const gridX = Math.round(point.x / gridSize); |
| const gridZ = Math.round(point.z / gridSize); |
| |
| if (isDrawingArea) { |
| selectionBox.visible = true; |
| const endPoint = new THREE.Vector3(gridX * gridSize, 0, gridZ * gridSize); |
| const minX = Math.min(areaStartPoint.x, endPoint.x); |
| const maxX = Math.max(areaStartPoint.x, endPoint.x); |
| const minZ = Math.min(areaStartPoint.z, endPoint.z); |
| const maxZ = Math.max(areaStartPoint.z, endPoint.z); |
| const center = new THREE.Vector3((minX + maxX)/2, 0.5, (minZ + maxZ)/2); |
| const size = new THREE.Vector3(maxX - minX + gridSize, 1, maxZ - minZ + gridSize); |
| selectionBox.position.copy(center); |
| selectionBox.scale.copy(size); |
| previewMesh.visible = false; |
| } else { |
| previewMesh.position.set(gridX * gridSize, 0, gridZ * gridSize); |
| if (previewMesh.userData.isBuilding) { |
| previewMesh.position.y = previewMesh.userData.size.y / 2; |
| } else { |
| const asset = ASSETS[currentTool.category][currentTool.type]; |
| previewMesh.position.y = asset.size[1] / 2; |
| } |
| previewMesh.visible = true; |
| } |
| } else if (previewMesh) { |
| previewMesh.visible = false; |
| } |
| } |
| |
| function handleKeyDown(event) { |
| if (isPlayMode) { |
| if (event.code === 'Escape') { |
| togglePlayMode(); |
| } |
| return; |
| } |
| if (event.code === 'KeyR') { |
| rotatePreview(1); |
| } |
| } |
| |
| function onPointerDown(event) { |
| if (isPlayMode || (previewMesh && !previewMesh.visible)) return; |
| if (event.target !== renderer.domElement) return; |
| |
| if (event.button === 2) { rotatePreview(1); return; } |
| if (event.button !== 0) return; |
| |
| const isRemoving = event.shiftKey; |
| |
| if (isRemoving) { |
| removeItemAtPointer(); |
| return; |
| } |
| |
| const pos = previewMesh.position.clone(); |
| |
| if (drawMode === 'area' && currentTool.category !== 'custom') { |
| isDrawingArea = true; |
| areaStartPoint.copy(pos); |
| } else { |
| addItem(pos, currentRotation); |
| updateLevelGeometry(); |
| } |
| } |
| |
| function onPointerUp(event) { |
| if (isPlayMode || event.button !== 0) return; |
| if (isDrawingArea) { |
| isDrawingArea = false; |
| selectionBox.visible = false; |
| const intersects = raycaster.intersectObject(placementPlane); |
| if (intersects.length > 0) { |
| const point = intersects[0].point; |
| const gridX = Math.round(point.x / gridSize); |
| const gridZ = Math.round(point.z / gridSize); |
| |
| const startX = Math.round(areaStartPoint.x / gridSize); |
| const startZ = Math.round(areaStartPoint.z / gridSize); |
| const endX = gridX; |
| const endZ = gridZ; |
| |
| for (let x = Math.min(startX, endX); x <= Math.max(startX, endX); x++) { |
| for (let z = Math.min(startZ, endZ); z <= Math.max(startZ, endZ); z++) { |
| addItem(new THREE.Vector3(x * gridSize, 0, z * gridSize), 0); |
| } |
| } |
| updateLevelGeometry(); |
| } |
| } |
| } |
| |
| function rotatePreview(direction) { |
| if (!previewMesh) return; |
| currentRotation += (Math.PI / 2) * direction; |
| previewMesh.rotation.y = currentRotation; |
| } |
| |
| function addItem(pos, rotation) { |
| const { category, type } = currentTool; |
| if (category === 'custom') { |
| const def = levelData.buildingDefinitions[type]; |
| if (!def) return; |
| const width = parseFloat(document.getElementById('building-width-input').value) || 10; |
| const height = width * def.aspectRatio; |
| levelData.buildingInstances.push({ |
| defId: type, |
| pos: {x: pos.x, y: height/2, z: pos.z}, |
| scale: {x: width, y: height, z: 0.2}, |
| rotation: rotation |
| }); |
| } else { |
| removeItemAtGrid(pos); |
| if (!levelData[category][type]) levelData[category][type] = []; |
| levelData[category][type].push([pos.x, pos.z, rotation]); |
| } |
| } |
| |
| function removeItemAtPointer() { |
| raycaster.setFromCamera(mouse, camera); |
| const intersectsBuildings = raycaster.intersectObjects(customBuildingMeshes); |
| if (intersectsBuildings.length > 0) { |
| const intersectedMesh = intersectsBuildings[0].object; |
| const instanceIndex = customBuildingMeshes.indexOf(intersectedMesh); |
| if (instanceIndex > -1) { |
| levelData.buildingInstances.splice(instanceIndex, 1); |
| updateLevelGeometry(); |
| return; |
| } |
| } |
| |
| const intersectsPlane = raycaster.intersectObject(placementPlane); |
| if (intersectsPlane.length > 0) { |
| const pos = intersectsPlane[0].point; |
| removeItemAtGrid(pos); |
| updateLevelGeometry(); |
| } |
| } |
| |
| function removeItemAtGrid(pos) { |
| const gridX = Math.round(pos.x / gridSize); |
| const gridZ = Math.round(pos.z / gridSize); |
| const keyToRemove = `${gridX * gridSize},${gridZ * gridSize}`; |
| for (const category in levelData) { |
| if (category === 'buildingDefinitions' || category === 'buildingInstances') continue; |
| for (const type in levelData[category]) { |
| levelData[category][type] = levelData[category][type].filter(item => { |
| const itemKey = `${item[0]},${item[1]}`; |
| return itemKey !== keyToRemove; |
| }); |
| } |
| } |
| } |
| |
| function updateLevelGeometry() { |
| const dummy = new THREE.Object3D(); |
| |
| for (const category in ASSETS) { |
| for (const type in ASSETS[category]) { |
| const mesh = instancedMeshes[category][type]; |
| const dataArray = levelData[category]?.[type] || []; |
| mesh.count = dataArray.length; |
| |
| for (let i = 0; i < dataArray.length; i++) { |
| const [x, z, rot] = dataArray[i]; |
| const asset = ASSETS[category][type]; |
| dummy.position.set(x, asset.size[1] / 2, z); |
| dummy.rotation.set(0, rot, 0); |
| dummy.updateMatrix(); |
| mesh.setMatrixAt(i, dummy.matrix); |
| } |
| mesh.instanceMatrix.needsUpdate = true; |
| } |
| } |
| renderCustomBuildings(); |
| rebuildCollisionData(); |
| } |
| |
| function renderCustomBuildings() { |
| while(customBuildingMeshes.length){ |
| const mesh = customBuildingMeshes.pop(); |
| scene.remove(mesh); |
| mesh.geometry.dispose(); |
| mesh.material.map?.dispose(); |
| mesh.material.dispose(); |
| } |
| |
| const textureLoader = new THREE.TextureLoader(); |
| for(const instance of levelData.buildingInstances) { |
| const def = levelData.buildingDefinitions[instance.defId]; |
| if (!def) continue; |
| |
| const geometry = new THREE.PlaneGeometry(instance.scale.x, instance.scale.y); |
| const texture = textureLoader.load(def.src); |
| const material = new THREE.MeshStandardMaterial({ map: texture, transparent: true, side: THREE.DoubleSide }); |
| const mesh = new THREE.Mesh(geometry, material); |
| |
| mesh.position.set(instance.pos.x, instance.pos.y, instance.pos.z); |
| mesh.rotation.y = instance.rotation; |
| mesh.castShadow = true; |
| mesh.receiveShadow = true; |
| |
| scene.add(mesh); |
| customBuildingMeshes.push(mesh); |
| } |
| } |
| |
| function rebuildCollisionData() { |
| collisionBBoxes = []; |
| for (const category in ASSETS) { |
| for (const type in ASSETS[category]) { |
| const asset = ASSETS[category][type]; |
| if (!asset.solid) continue; |
| const dataArray = levelData[category]?.[type] || []; |
| for(const item of dataArray) { |
| const [x, z, rot] = item; |
| const box = new THREE.Box3(); |
| const size = new THREE.Vector3(asset.size[0], asset.size[1], asset.size[2]); |
| const center = new THREE.Vector3(x, asset.size[1]/2, z); |
| box.setFromCenterAndSize(center, size); |
| collisionBBoxes.push(box); |
| } |
| } |
| } |
| |
| for (let i = 0; i < levelData.buildingInstances.length; i++) { |
| const instance = levelData.buildingInstances[i]; |
| const def = levelData.buildingDefinitions[instance.defId]; |
| if (!def || !def.isSolid) continue; |
| |
| const mesh = customBuildingMeshes[i]; |
| if(mesh) { |
| mesh.updateMatrixWorld(); |
| const box = new THREE.Box3().setFromObject(mesh); |
| collisionBBoxes.push(box); |
| } |
| } |
| } |
| |
| function clearLevel() { |
| if (!confirm("Вы уверены, что хотите полностью очистить уровень?")) return; |
| levelData = { floors: {}, walls: {}, objects: {}, buildingDefinitions: {}, buildingInstances: [] }; |
| updateCustomToolsUI(); |
| updateLevelGeometry(); |
| } |
| |
| function togglePlayMode() { |
| const blocker = document.getElementById('blocker'); |
| if(isPlayMode) { |
| isPlayMode = false; |
| document.exitPointerLock?.(); |
| const uiContainer = document.getElementById('ui-container'); |
| const joystickContainer = document.getElementById('joystick-container'); |
| uiContainer.style.display = 'flex'; |
| gridHelper.visible = true; |
| if(previewMesh) previewMesh.visible = true; |
| orbitControls.enabled = true; |
| player.visible = false; |
| blocker.style.display = 'none'; |
| joystickContainer.style.display = 'none'; |
| entranceHelper.visible = false; |
| camera.position.set(50, 50, 50); |
| camera.lookAt(0,0,0); |
| orbitControls.target.set(0, 0, 0); |
| onWindowResize(); |
| } else { |
| blocker.style.display = 'block'; |
| } |
| } |
| |
| function enterPlayMode() { |
| rebuildCollisionData(); |
| isPlayMode = true; |
| renderer.domElement.requestPointerLock?.(); |
| const uiContainer = document.getElementById('ui-container'); |
| const blocker = document.getElementById('blocker'); |
| const joystickContainer = document.getElementById('joystick-container'); |
| uiContainer.style.display = 'none'; |
| gridHelper.visible = false; |
| if(previewMesh) previewMesh.visible = false; |
| orbitControls.enabled = false; |
| player.visible = true; |
| player.position.set(0, 0, 0); |
| blocker.style.display = 'none'; |
| if ('ontouchstart' in window) { |
| joystickContainer.style.display = 'block'; |
| } |
| } |
| |
| async function saveProject() { |
| const projectName = document.getElementById('project-name').value.trim(); |
| if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; } |
| showSpinner(true); |
| try { |
| const response = await fetch('/api/project', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ name: projectName, data: levelData }) |
| }); |
| if (!response.ok) throw new Error('Ошибка при сохранении проекта.'); |
| alert(`Проект '${projectName}' успешно сохранен!`); |
| const projectList = document.getElementById('project-list'); |
| if (![...projectList.options].some(opt => opt.value === projectName)) { |
| const newOption = document.createElement('option'); |
| newOption.value = newOption.textContent = projectName; |
| projectList.appendChild(newOption); |
| } |
| } catch (error) { |
| alert(`Не удалось сохранить проект: ${error.message}`); |
| } finally { |
| showSpinner(false); |
| } |
| } |
| |
| async function loadProject() { |
| const projectName = document.getElementById('project-list').value; |
| if (!projectName) { alert("Пожалуйста, выберите проект для загрузки."); return; } |
| showSpinner(true); |
| try { |
| const response = await fetch(`/api/project/${projectName}`); |
| const result = await response.json(); |
| if (!response.ok) throw new Error('Проект не найден или ошибка загрузки.'); |
| |
| clearLevel(); |
| Object.assign(levelData, result.data); |
| if (!levelData.buildingDefinitions) levelData.buildingDefinitions = {}; |
| if (!levelData.buildingInstances) levelData.buildingInstances = []; |
| updateCustomToolsUI(); |
| updateLevelGeometry(); |
| document.getElementById('project-name').value = projectName; |
| |
| } catch (error) { |
| alert(`Не удалось загрузить проект: ${error.message}`); |
| } finally { |
| showSpinner(false); |
| } |
| } |
| |
| const moveDirection = new THREE.Vector3(); |
| function updatePlayer(deltaTime) { |
| const speed = playerSpeed * deltaTime; |
| moveDirection.set(0,0,0); |
| if (keyStates['KeyW'] || keyStates['ArrowUp']) moveDirection.z -= 1; |
| if (keyStates['KeyS'] || keyStates['ArrowDown']) moveDirection.z += 1; |
| if (keyStates['KeyA'] || keyStates['ArrowLeft']) moveDirection.x -= 1; |
| if (keyStates['KeyD'] || keyStates['ArrowRight']) moveDirection.x += 1; |
| |
| if (joystick.active) { |
| const maxRadius = 60; |
| moveDirection.x += joystick.vector.x / maxRadius; |
| moveDirection.z += joystick.vector.y / maxRadius; |
| } |
| |
| if (moveDirection.lengthSq() > 0) { |
| moveDirection.normalize().multiplyScalar(speed); |
| |
| const playerBox = new THREE.Box3().setFromObject(player); |
| |
| const playerBoxX = playerBox.clone().translate(new THREE.Vector3(moveDirection.x, 0, 0)); |
| let collisionX = false; |
| for(const colBox of collisionBBoxes) { |
| if (playerBoxX.intersectsBox(colBox)) { |
| collisionX = true; |
| break; |
| } |
| } |
| if(!collisionX) player.position.x += moveDirection.x; |
| |
| const playerBoxZ = playerBox.clone().translate(new THREE.Vector3(0, 0, moveDirection.z)); |
| let collisionZ = false; |
| for(const colBox of collisionBBoxes) { |
| if (playerBoxZ.intersectsBox(colBox)) { |
| collisionZ = true; |
| break; |
| } |
| } |
| if(!collisionZ) player.position.z += moveDirection.z; |
| } |
| |
| const cameraOffset = new THREE.Vector3(30, 30, 30); |
| camera.position.copy(player.position).add(cameraOffset); |
| camera.lookAt(player.position); |
| |
| updateEntranceHighlight(); |
| } |
| |
| function updateEntranceHighlight() { |
| let closestEntranceDist = Infinity; |
| let entranceToShow = null; |
| |
| for(let i=0; i < levelData.buildingInstances.length; i++) { |
| const instance = levelData.buildingInstances[i]; |
| const def = levelData.buildingDefinitions[instance.defId]; |
| if (!def || !def.entrance) continue; |
| |
| const dx = (def.entrance.x - 0.5) * instance.scale.x; |
| const dy = (def.entrance.y - 0.5) * instance.scale.y; |
| |
| let entrancePos = new THREE.Vector3(dx, dy, 0); |
| const rotationMatrix = new THREE.Matrix4().makeRotationY(instance.rotation); |
| entrancePos.applyMatrix4(rotationMatrix); |
| entrancePos.add(new THREE.Vector3(instance.pos.x, instance.pos.y, instance.pos.z)); |
| |
| const dist = player.position.distanceTo(entrancePos); |
| if (dist < 5 && dist < closestEntranceDist) { |
| closestEntranceDist = dist; |
| entranceToShow = entrancePos; |
| } |
| } |
| |
| if (entranceToShow) { |
| entranceHelper.position.copy(entranceToShow); |
| entranceHelper.visible = true; |
| } else { |
| entranceHelper.visible = false; |
| } |
| } |
| |
| function showSpinner(show) { |
| document.getElementById('loading-spinner').style.display = show ? 'block' : 'none'; |
| document.getElementById('blocker').style.display = show ? 'block' : 'none'; |
| } |
| |
| function animate() { |
| requestAnimationFrame(animate); |
| const deltaTime = clock.getDelta(); |
| |
| if (isPlayMode) { |
| updatePlayer(deltaTime); |
| } else { |
| orbitControls.update(); |
| } |
| renderer.render(scene, camera); |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
| ''' |
|
|
| @app.route('/') |
| def editor(): |
| projects = list_projects_from_hf() |
| return render_template_string(EDITOR_TEMPLATE, projects=projects) |
|
|
| @app.route('/api/project', methods=['POST']) |
| def save_project_api(): |
| data = request.get_json() |
| project_name = data.get('name') |
| if not project_name: |
| return jsonify({"error": "Project name is required"}), 400 |
|
|
| local_filename = f"{uuid4().hex}.json" |
| with open(local_filename, 'w', encoding='utf-8') as f: |
| json.dump(data, f) |
| |
| success = upload_project_to_hf(local_filename, project_name) |
|
|
| if os.path.exists(local_filename): |
| os.remove(local_filename) |
| |
| if success: |
| return jsonify({"message": "Project saved successfully"}), 201 |
| else: |
| return jsonify({"error": "Failed to upload project"}), 500 |
|
|
| @app.route('/api/project/<project_name>', methods=['GET']) |
| def load_project_api(project_name): |
| project_data = download_project_from_hf(project_name) |
| if project_data: |
| return jsonify(project_data) |
| else: |
| return jsonify({"error": "Project not found or failed to download"}), 404 |
|
|
| if __name__ == '__main__': |
| port = int(os.environ.get('PORT', 7860)) |
| app.run(debug=False, host='0.0.0.0', port=port) |