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