|
|
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) |