Testais / app.py
Kgshop's picture
Update app.py
5ae2454 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
import requests
load_dotenv()
app = Flask(__name__)
app.secret_key = 'level_designer_secret_key_12345'
REPO_ID = "Kgshop/Testai"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
def upload_project_to_hf(local_path, project_name):
if not HF_TOKEN_WRITE:
print("HF_TOKEN_WRITE is not set. Cannot upload.")
return False
try:
api = HfApi()
api.upload_file(
path_or_fileobj=local_path,
path_in_repo=f"projects/{project_name}.json",
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Save 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, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
if not token_to_use:
print("No Hugging Face token found for reading.")
return None
for attempt in range(retries + 1):
try:
local_path = hf_hub_download(
repo_id=REPO_ID,
filename=f"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:
print(f"Project '{project_name}' not found on HF Hub on attempt {attempt + 1}: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred during download attempt {attempt + 1}: {e}")
if attempt < retries:
time.sleep(delay)
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('projects/') and f.endswith('.json')]
return sorted(project_files)
except RepositoryNotFoundError:
print("Repository not found, creating 'projects' directory might be needed on first save.")
return []
except Exception as e:
print(f"Error listing projects from HF: {e}")
return []
EDITOR_TEMPLATE = '''
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Level Designer</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000; 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;}
#ui-panel {
background: rgba(0, 0, 0, 0.75);
padding: 15px;
border-right: 1px solid #444;
width: 300px;
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: 15px; padding-bottom: 15px; border-bottom: 1px solid #333; }
.ui-group:last-child { border-bottom: none; }
h3 { margin-top: 0; font-size: 1.1em; color: #00aaff; }
label { display: block; margin-bottom: 5px; font-size: 0.9em; }
input[type="number"], input[type="text"], select, input[type="file"] {
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;
}
button:hover { background: #0099ff; }
button.play-button { background: #22aa22; }
button.play-button:hover { background: #33cc33; }
.slider-container { margin-top: 10px; }
input[type="range"] { width: 100%; }
.radio-group label { display: inline-block; margin-right: 10px; cursor: pointer; }
.radio-group input { margin-right: 5px; }
#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: 100;
}
#blocker {
position: absolute; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: none;
}
#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; }
@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; }
}
</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>
<button id="play-mode-toggle" class="play-button" title="Переключиться в режим игры">Играть</button>
</div>
<div class="ui-group">
<h3>Проект</h3>
<label for="project-list" title="Выбрать существующий проект для загрузки">Загрузить проект:</label>
<select id="project-list">
<option value="">Выберите проект...</option>
{% for project in projects %}
<option value="{{ project }}">{{ project }}</option>
{% endfor %}
</select>
<button id="load-project" title="Загрузить выбранный проект">Загрузить</button>
<hr style="border-color: #333; margin: 15px 0;">
<label for="project-name" title="Введите имя для нового или существующего проекта">Имя проекта:</label>
<input type="text" id="project-name" placeholder="new-level-01">
<button id="save-project" title="Сохранить текущий проект на сервере">Сохранить</button>
</div>
<div class="ui-group">
<h3>Ландшафт</h3>
<label for="terrain-width" title="Ширина ландшафта по оси X">Ширина:</label>
<input type="number" id="terrain-width" value="100">
<label for="terrain-height" title="Глубина ландшафта по оси Z">Глубина:</label>
<input type="number" id="terrain-height" value="100">
<button id="create-terrain" title="Создать новый ландшафт с указанными размерами">Создать новый ландшафт</button>
</div>
<div class="ui-group">
<h3>Кисть</h3>
<div class="radio-group">
<label title="Поднять ландшафт"><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
<label title="Опустить ландшафт"><input type="radio" name="brush-mode" value="lower"> Опустить</label>
<label title="Добавить шум"><input type="radio" name="brush-mode" value="roughen"> Шум</label>
<label title="Сгладить ландшафт"><input type="radio" name="brush-mode" value="smooth"> Сгладить</label>
<label title="Выровнять до определенной высоты"><input type="radio" name="brush-mode" value="flatten"> Выровнять</label>
<label title="Рисовать текстуру"><input type="radio" name="brush-mode" value="paint"> Текстура</label>
<label title="Разместить объект"><input type="radio" name="brush-mode" value="place"> Объект</label>
</div>
<div class="slider-container">
<label for="brush-size" title="Радиус кисти">Размер: <span id="brush-size-value">10</span></label>
<input type="range" id="brush-size" min="1" max="50" value="10">
</div>
<div class="slider-container">
<label for="brush-strength" title="Интенсивность кисти">Сила: <span id="brush-strength-value">0.5</span></label>
<input type="range" id="brush-strength" min="0.1" max="2" step="0.1" value="0.5">
</div>
</div>
<div class="ui-group" id="texture-management-group">
<h3>Управление текстурами</h3>
<label for="texture-slot-select">Слот для изменения:</label>
<select id="texture-slot-select">
<option value="grass">Трава</option>
<option value="rock">Скалы</option>
<option value="dirt">Грунт</option>
<option value="snow">Снег</option>
<option value="sand">Песок</option>
</select>
<label for="texture-slot-name">Новое имя для слота:</label>
<input type="text" id="texture-slot-name" placeholder="например, Лава">
<button id="rename-texture-slot-btn">Переименовать</button>
<hr style="border-color: #333; margin: 15px 0;">
<label for="custom-texture-file">Текстура (Diffuse):</label>
<input type="file" id="custom-texture-file" accept="image/*" title="Выберите изображение для основной текстуры">
<label for="custom-normal-file">Карта нормалей (Normal):</label>
<input type="file" id="custom-normal-file" accept="image/*" title="Выберите изображение для карты нормалей">
<button id="update-texture-btn">Обновить текстуру</button>
</div>
<div class="ui-group">
<h3>Текстуры ландшафта</h3>
<div class="radio-group" id="texture-selector">
<label><input type="radio" name="texture-type" value="grass" checked> Трава</label>
<label><input type="radio" name="texture-type" value="rock"> Скалы</label>
<label><input type="radio" name="texture-type" value="dirt"> Грунт</label>
<label><input type="radio" name="texture-type" value="snow"> Снег</label>
<label><input type="radio" name="texture-type" value="sand"> Песок</label>
</div>
</div>
<div class="ui-group">
<h3>Объекты</h3>
<div class="radio-group" id="object-selector">
<label><input type="radio" name="object-type" value="grass" checked> Трава</label>
</div>
<button id="clear-objects" title="Удалить всю растительность с ландшафта">Очистить объекты</button>
</div>
</div>
</div>
<div id="blocker">
<div id="instructions">
<p style="font-size:36px">Нажмите, чтобы играть</p>
<p>
Движение: WASD<br/>
Прыжок: ПРОБЕЛ<br/>
Осмотр: МЫШЬ
</p>
<p>Нажмите ESC для выхода</p>
</div>
</div>
<div id="loading-spinner"></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/",
"simplex-noise": "https://unpkg.com/simplex-noise@4.0.1/dist/esm/simplex-noise.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { createNoise2D } from 'simplex-noise';
let scene, camera, renderer, orbitControls, pointerLockControls, composer, brushHelper, terrainMesh, sky, sun;
let raycaster = new THREE.Raycaster();
let mouse = new THREE.Vector2();
let isEditing = false;
const noise2D = createNoise2D();
let grassInstances, grassMaterial;
const MAX_GRASS_COUNT = 100000;
const terrainDimensions = { width: 100, height: 100, segmentsX: 100, segmentsY: 100 };
let isPlayMode = false;
let player, playerVelocity = new THREE.Vector3(), playerOnGround = false;
const playerHeight = 1.8;
const playerColliderRadius = 0.5;
const playerSpeed = 8.0;
const playerJumpHeight = 8.0;
const gravity = -30.0;
const keyStates = {};
const clock = new THREE.Clock();
const textureLoader = new THREE.TextureLoader();
let customTextures = {};
let customNormalMaps = {};
let flattenHeight = null;
const loadTexture = (url) => {
const tex = textureLoader.load(url);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.anisotropy = 16;
return tex;
};
const textures = {
grass: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/grasslight-big.jpg'),
rock: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/rock.jpg'),
dirt: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg'),
snow: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/snow.jpg'),
sand: loadTexture('https://cdn.jsdelivr.net/gh/mrdoob/three.js/examples/textures/terrain/sand-512.jpg'),
grassNormal: loadTexture('https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/terrain/grasslight-big-nm.jpg'),
rockNormal: loadTexture('https://raw.githubusercontent.com/mrdoob/three.js/master/examples/textures/terrain/rock-nm.jpg'),
dirtNormal: new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1),
snowNormal: new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1),
sandNormal: new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1)
};
Object.values(textures).forEach(tex => {
if (tex.isDataTexture) {
tex.needsUpdate = true;
} else {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
}
});
const terrainMaterial = new THREE.ShaderMaterial({
uniforms: {
grassTexture: { value: textures.grass },
rockTexture: { value: textures.rock },
dirtTexture: { value: textures.dirt },
snowTexture: { value: textures.snow },
sandTexture: { value: textures.sand },
grassNormalMap: { value: textures.grassNormal },
rockNormalMap: { value: textures.rockNormal },
dirtNormalMap: { value: textures.dirtNormal },
snowNormalMap: { value: textures.snowNormal },
sandNormalMap: { value: textures.sandNormal },
lightDirection: { value: new THREE.Vector3(0.5, 0.5, 0.5).normalize() }
},
vertexShader: `
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vViewPosition;
attribute vec4 color;
varying vec4 vColor;
attribute vec4 tangent;
varying vec3 vTangent;
varying vec3 vBitangent;
void main() {
vUv = uv;
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
vNormal = normalize(normalMatrix * normal);
vTangent = normalize(normalMatrix * tangent.xyz);
vBitangent = normalize(cross(vNormal, vTangent) * tangent.w);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform sampler2D grassTexture;
uniform sampler2D rockTexture;
uniform sampler2D dirtTexture;
uniform sampler2D snowTexture;
uniform sampler2D sandTexture;
uniform sampler2D grassNormalMap;
uniform sampler2D rockNormalMap;
uniform sampler2D dirtNormalMap;
uniform sampler2D snowNormalMap;
uniform sampler2D sandNormalMap;
uniform vec3 lightDirection;
varying vec2 vUv;
varying vec4 vColor;
varying vec3 vNormal;
varying vec3 vViewPosition;
varying vec3 vTangent;
varying vec3 vBitangent;
void main() {
vec2 uv_scaled = vUv * 30.0;
vec4 grass = texture2D(grassTexture, uv_scaled);
vec4 rock = texture2D(rockTexture, uv_scaled);
vec4 dirt = texture2D(dirtTexture, uv_scaled);
vec4 snow = texture2D(snowTexture, uv_scaled);
vec4 sand = texture2D(sandTexture, uv_scaled);
vec3 finalColor = grass.rgb;
finalColor = mix(finalColor, rock.rgb, vColor.r);
finalColor = mix(finalColor, dirt.rgb, vColor.g);
finalColor = mix(finalColor, snow.rgb, vColor.b);
finalColor = mix(finalColor, sand.rgb, vColor.a);
mat3 tbn = mat3(vTangent, vBitangent, vNormal);
vec3 grassNormal = texture2D(grassNormalMap, uv_scaled).xyz * 2.0 - 1.0;
vec3 rockNormal = texture2D(rockNormalMap, uv_scaled).xyz * 2.0 - 1.0;
vec3 dirtNormal = texture2D(dirtNormalMap, uv_scaled).xyz * 2.0 - 1.0;
vec3 snowNormal = texture2D(snowNormalMap, uv_scaled).xyz * 2.0 - 1.0;
vec3 sandNormal = texture2D(sandNormalMap, uv_scaled).xyz * 2.0 - 1.0;
vec3 finalNormal = grassNormal;
finalNormal = mix(finalNormal, rockNormal, vColor.r);
finalNormal = mix(finalNormal, dirtNormal, vColor.g);
finalNormal = mix(finalNormal, snowNormal, vColor.b);
finalNormal = mix(finalNormal, sandNormal, vColor.a);
vec3 normal = normalize(tbn * finalNormal);
float lighting = dot(normal, lightDirection) * 0.5 + 0.5;
gl_FragColor = vec4(finalColor * lighting, 1.0);
}
`
});
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(50, 50, 50);
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;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.maxPolarAngle = Math.PI / 2.1;
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.5);
scene.add(hemiLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
dirLight.position.set(100, 100, 50);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 4096;
dirLight.shadow.mapSize.height = 4096;
dirLight.shadow.camera.top = 150;
dirLight.shadow.camera.bottom = -150;
dirLight.shadow.camera.left = -150;
dirLight.shadow.camera.right = 150;
dirLight.shadow.bias = -0.001;
dirLight.shadow.normalBias = 0.05;
scene.add(dirLight);
terrainMaterial.uniforms.lightDirection.value = dirLight.position.clone().normalize();
sky = new Sky();
sky.scale.setScalar(450000);
scene.add(sky);
sun = new THREE.Vector3();
const effectController = {
turbidity: 10,
rayleigh: 3,
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
elevation: 4,
azimuth: 180,
};
const uniforms = sky.material.uniforms;
uniforms[ 'turbidity' ].value = effectController.turbidity;
uniforms[ 'rayleigh' ].value = effectController.rayleigh;
uniforms[ 'mieCoefficient' ].value = effectController.mieCoefficient;
uniforms[ 'mieDirectionalG' ].value = effectController.mieDirectionalG;
const phi = THREE.MathUtils.degToRad( 90 - effectController.elevation );
const theta = THREE.MathUtils.degToRad( effectController.azimuth );
sun.setFromSphericalCoords( 1, phi, theta );
uniforms[ 'sunPosition' ].value.copy( sun );
dirLight.position.copy(sun).multiplyScalar(100);
const brushGeometry = new THREE.CylinderGeometry(1, 1, 100, 32, 1, true);
const brushMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, transparent: true, opacity: 0.5 });
brushHelper = new THREE.Mesh(brushGeometry, brushMaterial);
brushHelper.visible = false;
scene.add(brushHelper);
initFoliage();
initPostprocessing();
initPlayer();
window.addEventListener('resize', onWindowResize);
renderer.domElement.addEventListener('pointermove', onPointerMove);
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointerup', onPointerUp);
renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
setupUIListeners();
animate();
}
function initPostprocessing() {
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssaoPass.kernelRadius = 16;
ssaoPass.minDistance = 0.005;
ssaoPass.maxDistance = 0.1;
composer.addPass(ssaoPass);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.5, 0.4, 0.85);
composer.addPass(bloomPass);
}
function initFoliage() {
if (grassInstances) {
scene.remove(grassInstances);
grassInstances.geometry.dispose();
grassMaterial.dispose();
}
const grassTexture = textureLoader.load('https://threejs.org/examples/textures/sprites/grass.png');
grassMaterial = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
map: { value: grassTexture }
},
vertexShader: `
uniform float time;
varying vec2 vUv;
void main() {
vUv = uv;
vec3 pos = position;
mat4 instanceMatrix_ = instanceMatrix;
vec3 instancePosition = vec3(instanceMatrix_[3][0], instanceMatrix_[3][1], instanceMatrix_[3][2]);
float windStrength = 0.2;
float windSpeed = 2.0;
float noise = (sin(instancePosition.x * 0.5 + time * windSpeed) + cos(instancePosition.z * 0.5 + time * windSpeed)) * 0.5 + 0.5;
if (pos.y > 0.1) {
pos.x += noise * windStrength;
}
gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
uniform sampler2D map;
varying vec2 vUv;
void main() {
vec4 texColor = texture2D(map, vUv);
if (texColor.a < 0.5) discard;
gl_FragColor = texColor;
}
`,
side: THREE.DoubleSide,
transparent: true
});
const grassBlade = new THREE.PlaneGeometry(0.8, 1.8);
grassBlade.translate(0, 0.9, 0);
grassInstances = new THREE.InstancedMesh(grassBlade, grassMaterial, MAX_GRASS_COUNT);
grassInstances.castShadow = true;
grassInstances.count = 0;
scene.add(grassInstances);
}
function initPlayer() {
player = new THREE.Group();
scene.add(player);
pointerLockControls = new PointerLockControls(camera, document.body);
const blocker = document.getElementById('blocker');
const instructions = document.getElementById('instructions');
instructions.addEventListener('click', () => pointerLockControls.lock());
pointerLockControls.addEventListener('lock', () => { instructions.style.display = 'none'; blocker.style.display = 'none'; });
pointerLockControls.addEventListener('unlock', () => { blocker.style.display = 'block'; instructions.style.display = ''; });
document.addEventListener('keydown', (event) => { keyStates[event.code] = true; });
document.addEventListener('keyup', (event) => { keyStates[event.code] = false; });
document.addEventListener('keydown', (event) => {
if (isPlayMode && event.code === 'Escape') {
togglePlayMode();
}
});
}
function setupUIListeners() {
document.getElementById('create-terrain').addEventListener('click', () => {
const width = parseInt(document.getElementById('terrain-width').value);
const height = parseInt(document.getElementById('terrain-height').value);
createTerrain(width, height);
});
document.getElementById('save-project').addEventListener('click', saveProject);
document.getElementById('load-project').addEventListener('click', loadProject);
document.getElementById('brush-size').addEventListener('input', (e) => {
document.getElementById('brush-size-value').textContent = e.target.value;
updateBrushHelper();
});
document.getElementById('brush-strength').addEventListener('input', (e) => {
document.getElementById('brush-strength-value').textContent = e.target.value;
});
document.getElementById('project-list').addEventListener('change', (e) => {
document.getElementById('project-name').value = e.target.value;
});
document.getElementById('burger-menu').addEventListener('click', () => {
document.getElementById('ui-panel').classList.toggle('open');
});
document.getElementById('clear-objects').addEventListener('click', () => {
if (confirm('Вы уверены, что хотите удалить все объекты?')) {
grassInstances.count = 0;
grassInstances.instanceMatrix.needsUpdate = true;
}
});
document.getElementById('play-mode-toggle').addEventListener('click', togglePlayMode);
document.getElementById('update-texture-btn').addEventListener('click', updateCustomTexture);
document.getElementById('rename-texture-slot-btn').addEventListener('click', renameTextureSlot);
document.getElementById('texture-slot-select').addEventListener('change', (e) => {
const slotName = e.target.value;
const textureSelector = document.getElementById('texture-selector');
const label = textureSelector.querySelector(`label input[value="${slotName}"]`).parentElement;
document.getElementById('texture-slot-name').value = label.innerText.trim();
});
}
function updateCustomTexture() {
const textureFileInput = document.getElementById('custom-texture-file');
const normalFileInput = document.getElementById('custom-normal-file');
const textureSlot = document.getElementById('texture-slot-select').value;
const textureFile = textureFileInput.files[0];
const normalFile = normalFileInput.files[0];
if (!textureFile && !normalFile) {
alert('Пожалуйста, выберите файл текстуры или карты нормалей.');
return;
}
const updateTexture = (file, isNormalMap) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target.result;
if (isNormalMap) {
customNormalMaps[textureSlot] = dataUrl;
} else {
customTextures[textureSlot] = dataUrl;
}
const newTexture = loadTexture(dataUrl);
const uniformName = textureSlot + (isNormalMap ? 'NormalMap' : 'Texture');
if (terrainMaterial.uniforms[uniformName]) {
if (terrainMaterial.uniforms[uniformName].value) {
terrainMaterial.uniforms[uniformName].value.dispose();
}
terrainMaterial.uniforms[uniformName].value = newTexture;
terrainMaterial.needsUpdate = true;
alert(`Карта ${isNormalMap ? 'нормалей' : 'текстуры'} для слота "${textureSlot}" успешно обновлена.`);
}
};
reader.readAsDataURL(file);
};
updateTexture(textureFile, false);
updateTexture(normalFile, true);
}
function renameTextureSlot() {
const slotValue = document.getElementById('texture-slot-select').value;
const newName = document.getElementById('texture-slot-name').value.trim();
if (!newName) {
alert("Имя слота не может быть пустым.");
return;
}
const textureSelector = document.getElementById('texture-selector');
const label = textureSelector.querySelector(`label input[value="${slotValue}"]`).parentElement;
label.childNodes[1].nodeValue = " " + newName;
const option = document.querySelector(`#texture-slot-select option[value="${slotValue}"]`);
option.textContent = newName;
alert(`Слот "${slotValue}" переименован в "${newName}".`);
}
function updateTextureUIAfterLoad(projectData) {
if (!projectData.textureNames) return;
const textureSelector = document.getElementById('texture-selector');
const slotSelect = document.getElementById('texture-slot-select');
Object.entries(projectData.textureNames).forEach(([slotValue, newName]) => {
const label = textureSelector.querySelector(`label input[value="${slotValue}"]`).parentElement;
if (label) {
label.childNodes[1].nodeValue = " " + newName;
}
const option = slotSelect.querySelector(`option[value="${slotValue}"]`);
if (option) {
option.textContent = newName;
}
});
}
function togglePlayMode() {
isPlayMode = !isPlayMode;
const toggleButton = document.getElementById('play-mode-toggle');
const uiPanel = document.getElementById('ui-container');
if (isPlayMode) {
toggleButton.textContent = "Редактор";
toggleButton.classList.remove('play-button');
uiPanel.style.display = 'none';
brushHelper.visible = false;
orbitControls.enabled = false;
const spawnRaycaster = new THREE.Raycaster(new THREE.Vector3(0, 100, 0), new THREE.Vector3(0, -1, 0));
const intersects = spawnRaycaster.intersectObject(terrainMesh);
if (intersects.length > 0) {
player.position.copy(intersects[0].point);
} else {
player.position.set(0, 0, 0);
}
player.position.y += playerHeight;
playerVelocity.set(0,0,0);
pointerLockControls.lock();
} else {
toggleButton.textContent = "Играть";
toggleButton.classList.add('play-button');
uiPanel.style.display = 'block';
orbitControls.enabled = true;
pointerLockControls.unlock();
}
}
function showSpinner(show) {
document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
}
function createTerrain(width, height, terrainData = null) {
if (terrainMesh) {
scene.remove(terrainMesh);
terrainMesh.geometry.dispose();
}
initFoliage();
terrainDimensions.width = width;
terrainDimensions.height = height;
terrainDimensions.segmentsX = Math.max(1, Math.round(width));
terrainDimensions.segmentsY = Math.max(1, Math.round(height));
const geometry = new THREE.PlaneGeometry(
terrainDimensions.width, terrainDimensions.height,
terrainDimensions.segmentsX, terrainDimensions.segmentsY
);
geometry.rotateX(-Math.PI / 2);
const colors = new Float32Array(geometry.attributes.position.count * 4);
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4));
if (terrainData) {
const positions = geometry.attributes.position;
for (let i = 0; i < positions.count; i++) {
positions.setY(i, terrainData.heights[i]);
}
if (terrainData.colors) {
geometry.attributes.color.array.set(terrainData.colors);
}
if (terrainData.foliage && terrainData.foliage.grass) {
const matrices = terrainData.foliage.grass;
grassInstances.count = matrices.length / 16;
for (let i = 0; i < grassInstances.count; i++) {
const matrix = new THREE.Matrix4();
matrix.fromArray(matrices, i * 16);
grassInstances.setMatrixAt(i, matrix);
}
grassInstances.instanceMatrix.needsUpdate = true;
}
if (terrainData.customTextures) {
customTextures = terrainData.customTextures;
Object.entries(customTextures).forEach(([slot, dataUrl]) => {
const newTexture = loadTexture(dataUrl);
const uniformName = slot + 'Texture';
if (terrainMaterial.uniforms[uniformName]) {
if (terrainMaterial.uniforms[uniformName].value) {
terrainMaterial.uniforms[uniformName].value.dispose();
}
terrainMaterial.uniforms[uniformName].value = newTexture;
}
});
}
if (terrainData.customNormalMaps) {
customNormalMaps = terrainData.customNormalMaps;
Object.entries(customNormalMaps).forEach(([slot, dataUrl]) => {
const newNormalMap = loadTexture(dataUrl);
const uniformName = slot + 'NormalMap';
if (terrainMaterial.uniforms[uniformName]) {
if (terrainMaterial.uniforms[uniformName].value) {
terrainMaterial.uniforms[uniformName].value.dispose();
}
terrainMaterial.uniforms[uniformName].value = newNormalMap;
}
});
}
terrainMaterial.needsUpdate = true;
updateTextureUIAfterLoad(terrainData);
}
geometry.computeTangents();
geometry.computeVertexNormals();
terrainMesh = new THREE.Mesh(geometry, terrainMaterial);
terrainMesh.castShadow = true;
terrainMesh.receiveShadow = true;
scene.add(terrainMesh);
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
function getBrushMode() {
return document.querySelector('input[name="brush-mode"]:checked').value;
}
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);
if (!terrainMesh) return;
const intersects = raycaster.intersectObject(terrainMesh);
if (intersects.length > 0) {
const intersectionPoint = intersects[0].point;
brushHelper.position.copy(intersectionPoint);
brushHelper.position.y = 0;
brushHelper.visible = true;
updateBrushHelper();
orbitControls.enabled = !isEditing;
if (isEditing) {
applyBrush(intersects[0]);
}
} else {
brushHelper.visible = false;
orbitControls.enabled = true;
}
}
function updateBrushHelper() {
const brushSize = parseFloat(document.getElementById('brush-size').value);
brushHelper.scale.set(brushSize, 1, brushSize);
}
function onPointerDown(event) {
if (isPlayMode) return;
if (event.button === 0 && mouse.x > -0.9) {
if (window.innerWidth < 800 && !document.getElementById('ui-panel').contains(event.target)) {
document.getElementById('ui-panel').classList.remove('open');
}
isEditing = true;
orbitControls.enabled = false;
if (getBrushMode() === 'flatten') {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(terrainMesh);
if (intersects.length > 0) {
flattenHeight = intersects[0].point.y;
}
}
}
}
function onPointerUp(event) {
if (isPlayMode) return;
if (event.button === 0) {
isEditing = false;
orbitControls.enabled = true;
flattenHeight = null;
}
}
function applyBrush(intersection) {
if (!terrainMesh) return;
const brushMode = getBrushMode();
switch(brushMode) {
case 'raise': case 'lower': sculptTerrain(intersection.point, brushMode); break;
case 'roughen': roughenTerrain(intersection.point); break;
case 'smooth': smoothTerrain(intersection.point); break;
case 'flatten': flattenTerrain(intersection.point); break;
case 'paint': paintTexture(intersection.point); break;
case 'place': placeObject(intersection); break;
}
}
function sculptTerrain(center, mode) {
const positions = terrainMesh.geometry.attributes.position;
const brushSize = parseFloat(document.getElementById('brush-size').value);
const brushStrength = parseFloat(document.getElementById('brush-strength').value);
const direction = (mode === 'raise') ? 1 : -1;
const vertex = new THREE.Vector3();
for (let i = 0; i < positions.count; i++) {
vertex.fromBufferAttribute(positions, i);
const distance = vertex.distanceTo(center);
if (distance < brushSize) {
const falloff = Math.pow(1 - (distance / brushSize), 2);
let currentY = positions.getY(i);
let newY = currentY + direction * falloff * brushStrength;
positions.setY(i, newY);
}
}
positions.needsUpdate = true;
terrainMesh.geometry.computeVertexNormals();
terrainMesh.geometry.computeTangents();
}
function roughenTerrain(center) {
const positions = terrainMesh.geometry.attributes.position;
const brushSize = parseFloat(document.getElementById('brush-size').value);
const brushStrength = parseFloat(document.getElementById('brush-strength').value);
const vertex = new THREE.Vector3();
const noiseFrequency = 1.5;
for (let i = 0; i < positions.count; i++) {
vertex.fromBufferAttribute(positions, i);
const distance = vertex.distanceTo(center);
if (distance < brushSize) {
const falloff = Math.pow(1 - (distance / brushSize), 2);
let noiseVal = noise2D(vertex.x * noiseFrequency, vertex.z * noiseFrequency);
let currentY = positions.getY(i);
let newY = currentY + noiseVal * falloff * brushStrength;
positions.setY(i, newY);
}
}
positions.needsUpdate = true;
terrainMesh.geometry.computeVertexNormals();
terrainMesh.geometry.computeTangents();
}
function smoothTerrain(center) {
const positions = terrainMesh.geometry.attributes.position;
const brushSize = parseFloat(document.getElementById('brush-size').value);
const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
const vertex = new THREE.Vector3();
const verticesInBrush = [];
let totalHeight = 0;
for (let i = 0; i < positions.count; i++) {
vertex.fromBufferAttribute(positions, i);
const distance = vertex.distanceTo(center);
if (distance < brushSize) {
verticesInBrush.push({ index: i, position: vertex.clone() });
totalHeight += vertex.y;
}
}
if (verticesInBrush.length === 0) return;
const averageHeight = totalHeight / verticesInBrush.length;
verticesInBrush.forEach(v => {
const distance = v.position.distanceTo(center);
const falloff = Math.pow(1 - (distance / brushSize), 2);
const currentY = positions.getY(v.index);
const newY = THREE.MathUtils.lerp(currentY, averageHeight, falloff * brushStrength);
positions.setY(v.index, newY);
});
positions.needsUpdate = true;
terrainMesh.geometry.computeVertexNormals();
terrainMesh.geometry.computeTangents();
}
function flattenTerrain(center) {
if (flattenHeight === null) return;
const positions = terrainMesh.geometry.attributes.position;
const brushSize = parseFloat(document.getElementById('brush-size').value);
const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
const vertex = new THREE.Vector3();
for (let i = 0; i < positions.count; i++) {
vertex.fromBufferAttribute(positions, i);
const distance = vertex.distanceTo(center);
if (distance < brushSize) {
const falloff = Math.pow(1 - (distance / brushSize), 2);
const currentY = positions.getY(i);
const newY = THREE.MathUtils.lerp(currentY, flattenHeight, falloff * brushStrength);
positions.setY(i, newY);
}
}
positions.needsUpdate = true;
terrainMesh.geometry.computeVertexNormals();
terrainMesh.geometry.computeTangents();
}
function paintTexture(center) {
const colors = terrainMesh.geometry.attributes.color;
const positions = terrainMesh.geometry.attributes.position;
const brushSize = parseFloat(document.getElementById('brush-size').value);
const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 0.1;
const textureType = document.querySelector('input[name="texture-type"]:checked').value;
const targetColor = new THREE.Vector4(0,0,0,0);
if(textureType === 'rock') targetColor.x = 1;
else if (textureType === 'dirt') targetColor.y = 1;
else if (textureType === 'snow') targetColor.z = 1;
else if (textureType === 'sand') targetColor.w = 1;
const vertex = new THREE.Vector3();
const currentColor = new THREE.Vector4();
for (let i = 0; i < positions.count; i++) {
vertex.fromBufferAttribute(positions, i);
const distance = vertex.distanceTo(center);
if (distance < brushSize) {
const falloff = Math.pow(1 - (distance / brushSize), 2);
currentColor.fromBufferAttribute(colors, i);
if (textureType === 'grass') {
currentColor.x = Math.max(0, currentColor.x - falloff * brushStrength);
currentColor.y = Math.max(0, currentColor.y - falloff * brushStrength);
currentColor.z = Math.max(0, currentColor.z - falloff * brushStrength);
currentColor.w = Math.max(0, currentColor.w - falloff * brushStrength);
} else {
currentColor.lerp(targetColor, falloff * brushStrength);
}
let sum = currentColor.x + currentColor.y + currentColor.z + currentColor.w;
if (sum > 1.0) {
currentColor.divideScalar(sum);
}
colors.setXYZW(i, currentColor.x, currentColor.y, currentColor.z, currentColor.w);
}
}
colors.needsUpdate = true;
}
function placeObject(intersection) {
const brushSize = parseFloat(document.getElementById('brush-size').value);
const density = parseFloat(document.getElementById('brush-strength').value) * 2;
const dummy = new THREE.Object3D();
for (let i = 0; i < density; i++) {
if (grassInstances.count >= MAX_GRASS_COUNT) break;
const randomPoint = new THREE.Vector2(
(Math.random() - 0.5) * brushSize * 2,
(Math.random() - 0.5) * brushSize * 2
);
dummy.position.set(
intersection.point.x + randomPoint.x,
intersection.point.y,
intersection.point.z + randomPoint.y
);
const placementRaycaster = new THREE.Raycaster(
new THREE.Vector3(dummy.position.x, 100, dummy.position.z),
new THREE.Vector3(0, -1, 0)
);
const placementIntersects = placementRaycaster.intersectObject(terrainMesh);
if (placementIntersects.length > 0) {
dummy.position.copy(placementIntersects[0].point);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.set(1, Math.random() * 0.5 + 0.75, 1);
dummy.updateMatrix();
grassInstances.setMatrixAt(grassInstances.count++, dummy.matrix);
}
}
grassInstances.instanceMatrix.needsUpdate = true;
}
async function saveProject() {
const projectName = document.getElementById('project-name').value.trim();
if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
if (!terrainMesh) { alert("Сначала создайте ландшафт для сохранения."); return; }
showSpinner(true);
const grassMatrices = [];
for (let i = 0; i < grassInstances.count; i++) {
const matrix = new THREE.Matrix4();
grassInstances.getMatrixAt(i, matrix);
grassMatrices.push(...matrix.elements);
}
const textureNames = {};
document.querySelectorAll('#texture-selector label').forEach(label => {
const input = label.querySelector('input');
const slotValue = input.value;
const name = label.innerText.trim();
textureNames[slotValue] = name;
});
const projectData = {
name: projectName,
width: terrainDimensions.width,
height: terrainDimensions.height,
heights: Array.from(terrainMesh.geometry.attributes.position.array.filter((_, i) => i % 3 === 1)),
colors: Array.from(terrainMesh.geometry.attributes.color.array),
foliage: {
grass: grassMatrices
},
customTextures: customTextures,
customNormalMaps: customNormalMaps,
textureNames: textureNames
};
try {
const response = await fetch('/api/project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(projectData)
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Ошибка при сохранении проекта.');
alert(`Проект '${projectName}' успешно сохранен!`);
const projectList = document.getElementById('project-list');
if (!Array.from(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(result.error || 'Ошибка при загрузке проекта.');
createTerrain(result.width, result.height, result);
document.getElementById('project-name').value = projectName;
document.getElementById('terrain-width').value = result.width;
document.getElementById('terrain-height').value = result.height;
} catch (error) {
alert(`Не удалось загрузить проект: ${error.message}`);
} finally {
showSpinner(false);
}
}
function updatePlayer(deltaTime) {
if (!terrainMesh) return;
let speedDelta = deltaTime * playerSpeed;
const moveVector = new THREE.Vector3(0, 0, 0);
if (keyStates['KeyW']) moveVector.z -= 1;
if (keyStates['KeyS']) moveVector.z += 1;
if (keyStates['KeyA']) moveVector.x -= 1;
if (keyStates['KeyD']) moveVector.x += 1;
if (playerOnGround && keyStates['Space']) {
playerVelocity.y = playerJumpHeight;
}
playerVelocity.y += gravity * deltaTime;
if (moveVector.length() > 0) {
moveVector.normalize().applyQuaternion(camera.quaternion).multiplyScalar(speedDelta);
const collisionRay = new THREE.Raycaster(player.position, moveVector.clone().normalize());
const collisionResults = collisionRay.intersectObject(terrainMesh);
if (collisionResults.length === 0 || collisionResults[0].distance > playerColliderRadius) {
player.position.x += moveVector.x;
player.position.z += moveVector.z;
}
}
player.position.y += playerVelocity.y * deltaTime;
raycaster.set(player.position, new THREE.Vector3(0, -1, 0));
const intersects = raycaster.intersectObject(terrainMesh);
if (intersects.length > 0) {
const groundHeight = intersects[0].point.y;
if (player.position.y < groundHeight + playerHeight) {
player.position.y = groundHeight + playerHeight;
playerVelocity.y = 0;
playerOnGround = true;
} else {
playerOnGround = false;
}
}
camera.position.copy(player.position);
}
function animate() {
requestAnimationFrame(animate);
const deltaTime = clock.getDelta();
const elapsedTime = clock.getElapsedTime();
if(grassMaterial) {
grassMaterial.uniforms.time.value = elapsedTime;
}
if (isPlayMode && pointerLockControls.isLocked) {
updatePlayer(deltaTime);
} else {
orbitControls.update();
}
composer.render();
}
init();
createTerrain(100, 100);
</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 to Hugging Face Hub"}), 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)