Testais2 / app.py
Kgshop's picture
Update app.py
4e5b6b5 verified
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)