Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import atexit | |
| from flask import Flask, Response, request | |
| from flask_socketio import SocketIO, emit, join_room, leave_room | |
| app = Flask(__name__) | |
| app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your_default_secret_key') | |
| socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') | |
| CHUNK_SIZE = 16 | |
| WORLD_FILE = 'world_data.json' | |
| server_world = {} | |
| server_players = {} | |
| server_player_count = 0 | |
| LAND_HEIGHT = 4 | |
| SEA_LEVEL = 2 | |
| def get_chunk_key(x, z): | |
| return f"{int(x // CHUNK_SIZE)},{int(z // CHUNK_SIZE)}" | |
| def get_block_server(wx, wy, wz): | |
| chunk_key = get_chunk_key(wx, wz) | |
| chunk = server_world.get(chunk_key) | |
| if not chunk: | |
| return None | |
| lx = int((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| lz = int((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| return chunk['blocks'].get(str(lx), {}).get(str(wy), {}).get(str(lz), None) | |
| def set_block_server(wx, wy, wz, block_type): | |
| chunk_key = get_chunk_key(wx, wz) | |
| if chunk_key not in server_world: | |
| server_world[chunk_key] = { 'blocks': {}, 'maxHeight': 0 } | |
| chunk = server_world[chunk_key] | |
| lx = int((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| lz = int((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| lx, wy, lz = str(lx), str(wy), str(lz) | |
| if lx not in chunk['blocks']: chunk['blocks'][lx] = {} | |
| if wy not in chunk['blocks'][lx]: chunk['blocks'][lx][wy] = {} | |
| old_type = chunk['blocks'][lx][wy].get(lz) | |
| if block_type is None: | |
| if lz in chunk['blocks'][lx][wy]: | |
| del chunk['blocks'][lx][wy][lz] | |
| if not chunk['blocks'][lx][wy]: | |
| del chunk['blocks'][lx][wy] | |
| if not chunk['blocks'][lx]: | |
| del chunk['blocks'][lx] | |
| else: | |
| chunk['blocks'][lx][wy][lz] = block_type | |
| chunk['maxHeight'] = max(chunk['maxHeight'], int(wy)) | |
| return old_type != block_type | |
| def generate_chunk_server(chunkX, chunkZ): | |
| chunk_key = f"{chunkX},{chunkZ}" | |
| if chunk_key in server_world: | |
| return | |
| chunk = { | |
| 'blocks': {}, | |
| 'maxHeight': 0 | |
| } | |
| for x in range(CHUNK_SIZE): | |
| x_str = str(x) | |
| chunk['blocks'][x_str] = {} | |
| for z in range(CHUNK_SIZE): | |
| z_str = str(z) | |
| for y in range(LAND_HEIGHT): | |
| y_str = str(y) | |
| if y_str not in chunk['blocks'][x_str]: chunk['blocks'][x_str][y_str] = {} | |
| block_type = 'earth' | |
| if y == LAND_HEIGHT - 1: block_type = 'grass' | |
| chunk['blocks'][x_str][y_str][z_str] = block_type | |
| for y in range(SEA_LEVEL): | |
| y_str = str(y) | |
| world_x = chunkX * CHUNK_SIZE + x | |
| world_z = chunkZ * CHUNK_SIZE + z | |
| ck = get_chunk_key(world_x, world_z) | |
| c = server_world.get(ck, chunk) | |
| lx = int((world_x % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| lz = int((world_z % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| block_exists = c.get('blocks', {}).get(str(lx), {}).get(str(y), {}).get(str(lz)) | |
| if block_exists is None: | |
| if y_str not in chunk['blocks'][x_str]: chunk['blocks'][x_str][y_str] = {} | |
| chunk['blocks'][x_str][y_str][z_str] = 'sea' | |
| chunk['maxHeight'] = LAND_HEIGHT - 1 | |
| server_world[chunk_key] = chunk | |
| def load_world(): | |
| global server_world | |
| if os.path.exists(WORLD_FILE): | |
| try: | |
| with open(WORLD_FILE, 'r') as f: | |
| server_world = json.load(f) | |
| except (json.JSONDecodeError, FileNotFoundError): | |
| server_world = {} | |
| def save_world(): | |
| with open(WORLD_FILE, 'w') as f: | |
| json.dump(server_world, f) | |
| def handle_connect(): | |
| global server_player_count | |
| server_player_count += 1 | |
| def handle_disconnect(): | |
| global server_player_count | |
| server_player_count -= 1 | |
| if request.sid in server_players: | |
| emit('player_left', {'sid': request.sid}, broadcast=True, include_self=False) | |
| del server_players[request.sid] | |
| def handle_join(data): | |
| sid = request.sid | |
| nickname = data.get('nickname', f'Player_{sid[:4]}') | |
| initial_pos = {'x': 0, 'y': LAND_HEIGHT * 0.5 + 2.0, 'z': 0} | |
| server_players[sid] = { | |
| 'nickname': nickname, | |
| 'position': initial_pos, | |
| 'rotationY': 0, | |
| 'blockType': 'grass' | |
| } | |
| emit('player_joined', { | |
| 'sid': sid, | |
| 'nickname': nickname, | |
| 'position': initial_pos, | |
| 'rotationY': 0 | |
| }, broadcast=True) | |
| other_players = {s: player for s, player in server_players.items() if s != sid} | |
| emit('current_players', other_players) | |
| initial_chunk_x = int(initial_pos['x'] // CHUNK_SIZE) | |
| initial_chunk_z = int(initial_pos['z'] // CHUNK_SIZE) | |
| chunk_data_to_send = {} | |
| for cx in range(initial_chunk_x - 1, initial_chunk_x + 2): | |
| for cz in range(initial_chunk_z - 1, initial_chunk_z + 2): | |
| key = f"{cx},{cz}" | |
| generate_chunk_server(cx, cz) | |
| if key in server_world: | |
| chunk_data_to_send[key] = server_world[key] | |
| emit('initial_world_data', chunk_data_to_send) | |
| def handle_request_chunk(data): | |
| cx = data['chunkX'] | |
| cz = data['chunkZ'] | |
| key = f"{cx},{cz}" | |
| generate_chunk_server(cx, cz) | |
| if key in server_world: | |
| emit('chunk_data', { 'chunkKey': key, 'chunkData': server_world[key] }) | |
| def handle_player_state(data): | |
| sid = request.sid | |
| if sid in server_players: | |
| player_data = server_players[sid] | |
| player_data['position'] = data['position'] | |
| player_data['rotationY'] = data['rotationY'] | |
| emit('player_state_update', { | |
| 'sid': sid, | |
| 'position': data['position'], | |
| 'rotationY': data['rotationY'] | |
| }, broadcast=True, include_self=False) | |
| def handle_block_change(data): | |
| sid = request.sid | |
| wx = data['wx'] | |
| wy = data['wy'] | |
| wz = data['wz'] | |
| block_type = data['type'] | |
| player_pos = server_players[sid]['position'] | |
| dist_sq = (player_pos['x'] - wx*1)**2 + (player_pos['y'] - wy*0.5)**2 + (player_pos['z'] - wz*1)**2 | |
| if dist_sq > 25*25: | |
| return | |
| effective_wy = wy | |
| if block_type is not None: | |
| chunk_key_at_wx_wz = get_chunk_key(wx, wz) | |
| chunk_at_wx_wz = server_world.get(chunk_key_at_wx_wz) | |
| current_stack_height = 0 | |
| if chunk_at_wx_wz: | |
| lx = int((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| lz = int((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE | |
| for h in range(chunk_at_wx_wz['maxHeight'] + 2, -1, -1): | |
| if str(h) in chunk_at_wx_wz['blocks'].get(str(lx),{}) and str(lz) in chunk_at_wx_wz['blocks'].get(str(lx),{}).get(str(h),{}): | |
| block = chunk_at_wx_wz['blocks'][str(lx)][str(h)][str(lz)] | |
| if block is not None and block != 'sea': | |
| current_stack_height = h + 1 | |
| break | |
| effective_wy = max(current_stack_height, SEA_LEVEL if block_type == 'sea' else 0) | |
| if set_block_server(wx, effective_wy, wz, block_type): | |
| chunk_key_changed = get_chunk_key(wx, wz) | |
| emit('block_changed', { | |
| 'chunkKey': chunk_key_changed, | |
| 'wx': wx, | |
| 'wy': effective_wy, | |
| 'wz': wz, | |
| 'type': block_type | |
| }, broadcast=True) | |
| html_content = """ | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Battime - Voxel World Builder</title> | |
| <style> | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| margin: 0; | |
| padding: 0; | |
| background-color: #1a1a1a; | |
| color: #fff; | |
| overflow: hidden; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| } | |
| #game-container { | |
| width: 100vw; | |
| height: 100vh; | |
| position: relative; | |
| overflow: hidden; | |
| cursor: pointer; | |
| } | |
| canvas { | |
| display: block; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #toolbar { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background-color: rgba(40, 40, 40, 0.85); | |
| padding: 10px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); | |
| display: flex; | |
| gap: 10px; | |
| z-index: 10; | |
| } | |
| .block-button { | |
| width: 60px; | |
| height: 60px; | |
| border: 2px solid #555; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.2s ease-in-out; | |
| background-size: cover; | |
| background-position: center; | |
| image-rendering: pixelated; | |
| } | |
| .block-button:hover { | |
| border-color: #00aaff; | |
| transform: scale(1.1); | |
| } | |
| .block-button.selected { | |
| border-color: #3498db; | |
| transform: scale(1.15); | |
| box-shadow: 0 0 15px #3498db, 0 0 5px #3498db inset; | |
| } | |
| #joystick-container { | |
| position: absolute; | |
| bottom: 30px; | |
| left: 30px; | |
| width: 150px; | |
| height: 150px; | |
| display: none; | |
| z-index: 10; | |
| } | |
| #joystick-base { | |
| position: absolute; | |
| width: 150px; | |
| height: 150px; | |
| background: rgba(50, 50, 50, 0.5); | |
| border-radius: 50%; | |
| border: 2px solid rgba(255, 255, 255, 0.3); | |
| } | |
| #joystick-handle { | |
| position: absolute; | |
| width: 70px; | |
| height: 70px; | |
| background: rgba(100, 100, 100, 0.8); | |
| border-radius: 50%; | |
| left: 40px; | |
| top: 40px; | |
| } | |
| #info-panel { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| background-color: rgba(40, 40, 40, 0.8); | |
| padding: 10px 15px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| text-align: right; | |
| z-index: 10; | |
| pointer-events: auto; | |
| } | |
| .info-button { | |
| background-color: #3498db; | |
| color: white; | |
| padding: 5px 10px; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| margin-top: 10px; | |
| text-align: center; | |
| transition: background-color 0.2s; | |
| display: inline-block; | |
| } | |
| .info-button:hover { | |
| background-color: #2980b9; | |
| } | |
| #custom-block-modal { | |
| display: none; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background-color: #2c2c2c; | |
| padding: 25px; | |
| border-radius: 15px; | |
| z-index: 100; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.7); | |
| border: 1px solid rgba(255,255,255,0.2); | |
| width: 300px; | |
| } | |
| #custom-block-modal h3 { margin-top: 0; text-align: center; } | |
| #custom-block-modal .form-group { margin-bottom: 15px; } | |
| #custom-block-modal label { display: block; margin-bottom: 5px; } | |
| #custom-block-modal input[type="text"], #custom-block-modal input[type="file"] { | |
| width: 100%; | |
| padding: 8px; | |
| border-radius: 5px; | |
| border: 1px solid #555; | |
| background-color: #444; | |
| color: #fff; | |
| box-sizing: border-box; | |
| } | |
| #custom-block-modal button { | |
| width: 100%; | |
| padding: 10px; | |
| border: none; | |
| border-radius: 5px; | |
| background-color: #3498db; | |
| color: white; | |
| cursor: pointer; | |
| font-size: 16px; | |
| } | |
| #custom-block-modal button:hover { background-color: #2980b9; } | |
| #nickname-modal { | |
| display: flex; | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| background-color: rgba(0,0,0,0.8); | |
| z-index: 101; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| } | |
| #nickname-modal h2 { color: white; } | |
| #nickname-modal input { | |
| padding: 10px; | |
| margin: 10px 0; | |
| border-radius: 5px; | |
| border: 1px solid #555; | |
| background-color: #444; | |
| color: #fff; | |
| font-size: 16px; | |
| } | |
| #nickname-modal button { | |
| padding: 10px 20px; | |
| border: none; | |
| border-radius: 5px; | |
| background-color: #3498db; | |
| color: white; | |
| cursor: pointer; | |
| font-size: 16px; | |
| } | |
| #nickname-modal button:hover { | |
| background-color: #2980b9; | |
| } | |
| #modal-backdrop { | |
| display: none; | |
| position: fixed; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| background-color: rgba(0,0,0,0.6); | |
| z-index: 99; | |
| } | |
| .player-name-label { | |
| position: absolute; | |
| top: 0; left: 0; | |
| color: white; | |
| font-size: 12px; | |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.8); | |
| pointer-events: none; | |
| transform: translate(-50%, -100%); | |
| white-space: nowrap; | |
| } | |
| #crosshair { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| width: 4px; | |
| height: 4px; | |
| background-color: rgba(255, 255, 255, 0.7); | |
| border-radius: 50%; | |
| transform: translate(-50%, -50%); | |
| z-index: 10; | |
| pointer-events: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="nickname-modal"> | |
| <h2>Выберите никнейм</h2> | |
| <input type="text" id="nickname-input" placeholder="Введите ваш никнейм" maxlength="15"> | |
| <button id="join-button">Присоединиться</button> | |
| </div> | |
| <div id="game-container"> | |
| <div id="crosshair"></div> | |
| <div id="toolbar"> | |
| <div class="block-button" id="grass" title="Трава"></div> | |
| <div class="block-button" id="earth" title="Земля"></div> | |
| <div class="block-button" id="sea" title="Море"></div> | |
| <div class="block-button" id="remove" title="Удалить блок"></div> | |
| <div class="block-button" id="add-custom-block" title="Создать свой блок" style="font-size: 40px; text-align: center; line-height: 55px;">+</div> | |
| </div> | |
| <div id="info-panel"> | |
| <div>Battime Engine v4.3</div> | |
| <div>Управление: WASD/Мышь</div> | |
| <div>На мобильных: Джойстик/Тап</div> | |
| <input type="file" id="face-upload" accept="image/png" style="display: none;"> | |
| <label for="face-upload" class="info-button">Загрузить лицо</label> | |
| </div> | |
| <div id="joystick-container"> | |
| <div id="joystick-base"></div> | |
| <div id="joystick-handle"></div> | |
| </div> | |
| </div> | |
| <div id="modal-backdrop"></div> | |
| <div id="custom-block-modal"> | |
| <h3>Создать новый блок</h3> | |
| <div class="form-group"> | |
| <label for="block-name">Имя блока (англ.)</label> | |
| <input type="text" id="block-name" placeholder="my_awesome_block"> | |
| </div> | |
| <div class="form-group"> | |
| <label for="texture-upload">Текстура (PNG)</label> | |
| <input type="file" id="texture-upload" accept="image/png"> | |
| </div> | |
| <div class="form-group"> | |
| <label><input type="checkbox" id="block-solid" checked> Твердый блок</label> | |
| </div> | |
| <button id="create-block-btn">Создать</button> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tween.js/18.6.4/tween.umd.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.3.2/socket.io.min.js"></script> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.128.0/build/three.module.js", | |
| "three/examples/jsm/": "https://unpkg.com/three@0.128.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; | |
| import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; | |
| import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; | |
| const socket = io(); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| scene.fog = new THREE.Fog(0x87ceeb, 50, 150); | |
| const gameContainer = document.getElementById('game-container'); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.2; | |
| gameContainer.appendChild(renderer.domElement); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| const hemisphereLight = new THREE.HemisphereLight(0xffffbb, 0x080820, 1.0); | |
| scene.add(hemisphereLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
| directionalLight.position.set(50, 50, 25); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.mapSize.width = 2048; | |
| directionalLight.shadow.mapSize.height = 2048; | |
| directionalLight.shadow.camera.left = -60; | |
| directionalLight.shadow.camera.right = 60; | |
| directionalLight.shadow.camera.top = 60; | |
| directionalLight.shadow.camera.bottom = -60; | |
| directionalLight.shadow.bias = -0.0005; | |
| scene.add(directionalLight); | |
| const renderScene = new RenderPass(scene, camera); | |
| const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.8, 0.4, 0.85); | |
| const composer = new EffectComposer(renderer); | |
| composer.addPass(renderScene); | |
| composer.addPass(bloomPass); | |
| const CHUNK_SIZE = 16; | |
| const RENDER_DISTANCE = 5; | |
| const blockSize = 1; | |
| const blockHeight = 0.5; | |
| const chunks = new Map(); | |
| const LAND_HEIGHT = 4; | |
| const SEA_LEVEL = 2; | |
| const textureLoader = new THREE.TextureLoader(); | |
| function createTexture(color1, color2) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 64; | |
| canvas.height = 64; | |
| const context = canvas.getContext('2d'); | |
| context.fillStyle = color1; | |
| context.fillRect(0, 0, 64, 64); | |
| context.fillStyle = color2; | |
| for(let i = 0; i < 100; i++) { | |
| const x = Math.random() * 64; | |
| const y = Math.random() * 64; | |
| const size = Math.random() * 3; | |
| context.fillRect(x, y, size, size); | |
| } | |
| context.fillStyle = color1; | |
| context.fillRect(0, 0, 64, 2); | |
| context.fillRect(0, 62, 64, 2); | |
| context.fillRect(0, 0, 2, 64); | |
| context.fillRect(62, 0, 2, 64); | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.magFilter = THREE.NearestFilter; | |
| return texture; | |
| } | |
| function createGradientTexture(colorTop, colorBottom) { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = 1; | |
| canvas.height = 256; | |
| const context = canvas.getContext('2d'); | |
| const gradient = context.createLinearGradient(0, 0, 0, 256); | |
| gradient.addColorStop(0, colorTop); | |
| gradient.addColorStop(1, colorBottom); | |
| context.fillStyle = gradient; | |
| context.fillRect(0, 0, 1, 256); | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.magFilter = THREE.LinearFilter; | |
| return texture; | |
| } | |
| function createSkybox() { | |
| const skyGeometry = new THREE.SphereGeometry(500, 32, 15); | |
| const skyMaterial = new THREE.MeshBasicMaterial({ | |
| map: createGradientTexture('#87ceeb', '#ffffff'), | |
| side: THREE.BackSide, | |
| }); | |
| const skybox = new THREE.Mesh(skyGeometry, skyMaterial); | |
| scene.add(skybox); | |
| } | |
| const baseTextures = { | |
| grass: createTexture('#4a7d4a', '#6b8e23'), | |
| earth: createTexture('#7a5a4a', '#8d6e63'), | |
| sea: createTexture('#1e88e5', '#42a5f5'), | |
| player_skin: createTexture('#f0c8a2', '#e0b892'), | |
| player_face: createTexture('#212121', '#ffeb3b'), | |
| }; | |
| const blockTypes = { | |
| grass: { solid: true, texture: baseTextures.grass }, | |
| earth: { solid: true, texture: baseTextures.earth }, | |
| sea: { solid: false, texture: baseTextures.sea, opacity: 0.8 } | |
| }; | |
| const blockGeometries = {}; | |
| const blockMaterials = {}; | |
| function initializeBlockAssets() { | |
| for (const type in blockTypes) { | |
| blockGeometries[type] = new THREE.BoxGeometry(blockSize, blockHeight, blockSize); | |
| const info = blockTypes[type]; | |
| if (info.solid) { | |
| blockMaterials[type] = new THREE.MeshStandardMaterial({ | |
| map: info.texture, | |
| roughness: 0.8, | |
| metalness: 0.1 | |
| }); | |
| } else { | |
| blockMaterials[type] = new THREE.MeshLambertMaterial({ | |
| map: info.texture, | |
| transparent: true, | |
| opacity: info.opacity || 1.0 | |
| }); | |
| blockMaterials[type].alphaTest = 0.01; | |
| } | |
| } | |
| document.getElementById('grass').style.backgroundImage = `url(${baseTextures.grass.image.toDataURL()})`; | |
| document.getElementById('earth').style.backgroundImage = `url(${baseTextures.earth.image.toDataURL()})`; | |
| document.getElementById('sea').style.backgroundImage = `url(${baseTextures.sea.image.toDataURL()})`; | |
| document.getElementById('remove').style.backgroundImage = `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="red"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>')`; | |
| } | |
| initializeBlockAssets(); | |
| createSkybox(); | |
| function createPlayerAvatar() { | |
| const playerGroup = new THREE.Group(); | |
| const headSize = 0.5; | |
| const bodyWidth = 0.5; | |
| const bodyHeight = 0.6; | |
| const limbSize = 0.2; | |
| const skinMaterial = new THREE.MeshStandardMaterial({ map: baseTextures.player_skin, roughness: 0.8, metalness: 0.1 }); | |
| const faceMaterial = new THREE.MeshStandardMaterial({ map: baseTextures.player_face, roughness: 0.8, metalness: 0.1 }); | |
| const headMaterials = [ skinMaterial, skinMaterial, skinMaterial, skinMaterial, faceMaterial, skinMaterial ]; | |
| const head = new THREE.Mesh(new THREE.BoxGeometry(headSize, headSize, headSize), headMaterials); | |
| head.position.y = bodyHeight / 2 + headSize / 2; | |
| const torso = new THREE.Mesh(new THREE.BoxGeometry(bodyWidth, bodyHeight, 0.3), skinMaterial); | |
| const armGeo = new THREE.BoxGeometry(limbSize, bodyHeight * 0.9, limbSize); | |
| const leftArm = new THREE.Mesh(armGeo, skinMaterial); | |
| const rightArm = new THREE.Mesh(armGeo, skinMaterial); | |
| leftArm.position.set(-bodyWidth/2 - limbSize/2, -0.05, 0); | |
| rightArm.position.set(bodyWidth/2 + limbSize/2, -0.05, 0); | |
| const legGeo = new THREE.BoxGeometry(limbSize, bodyHeight * 0.95, limbSize); | |
| const leftLeg = new THREE.Mesh(legGeo, skinMaterial); | |
| const rightLeg = new THREE.Mesh(legGeo, skinMaterial); | |
| leftLeg.position.set(-bodyWidth/4, -bodyHeight/2 - (bodyHeight * 0.95)/2, 0); | |
| rightLeg.position.set(bodyWidth/4, -bodyHeight/2 - (bodyHeight * 0.95)/2, 0); | |
| playerGroup.add(head, torso, leftArm, rightArm, leftLeg, rightLeg); | |
| playerGroup.castShadow = true; | |
| playerGroup.children.forEach(c => { c.castShadow = true; c.receiveShadow = true; }); | |
| playerGroup.userData = { head, leftArm, rightArm, leftLeg, rightLeg, skinMaterial, faceMaterial, bodyHeight, headSize, bodyWidth }; | |
| return playerGroup; | |
| } | |
| const player = createPlayerAvatar(); | |
| scene.add(player); | |
| const cameraPivot = new THREE.Object3D(); | |
| player.add(cameraPivot); | |
| cameraPivot.add(camera); | |
| camera.position.set(0, 1.8, 8); | |
| camera.lookAt(player.position.clone().add(new THREE.Vector3(0, 1.5, 0))); | |
| const otherPlayers = {}; | |
| let currentBlockType = 'grass'; | |
| const keys = {}; | |
| let localPlayerId = null; | |
| const playerVelocity = new THREE.Vector3(); | |
| const playerMoveDirection = new THREE.Vector3(); | |
| const playerOnGround = false; | |
| const playerSpeed = 4.0; | |
| const gravity = -20; | |
| const playerWidth = 0.6; | |
| const playerHeight = 1.8; | |
| function getChunkKey(x, z) { | |
| return `${Math.floor(x / CHUNK_SIZE)},${Math.floor(z / CHUNK_SIZE)}`; | |
| } | |
| function getBlockClient(wx, wy, wz) { | |
| const chunkKey = getChunkKey(wx, wz); | |
| const chunk = chunks.get(chunkKey); | |
| if (!chunk) return null; | |
| const lx = ((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| const lz = ((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| return chunk.blocks?.[lx]?.[wy]?.[lz]; | |
| } | |
| function isBlockSolidClient(wx, wy, wz) { | |
| const type = getBlockClient(wx, wy, wz); | |
| return type !== null && blockTypes[type]?.solid === true; | |
| } | |
| function updateChunkMesh(chunk) { | |
| for (const type in chunk.instancedMeshes) { | |
| const mesh = chunk.instancedMeshes[type]; | |
| scene.remove(mesh); | |
| mesh.geometry.dispose(); | |
| if (mesh.material.map) mesh.material.map.dispose(); | |
| mesh.material.dispose(); | |
| } | |
| chunk.instancedMeshes = {}; | |
| const instances = {}; | |
| for (const type in blockTypes) { instances[type] = []; } | |
| const dummy = new THREE.Object3D(); | |
| for (const x_str in chunk.blocks) { | |
| const x = parseInt(x_str, 10); | |
| for (const y_str in chunk.blocks[x_str]) { | |
| const y = parseInt(y_str, 10); | |
| for (const z_str in chunk.blocks[x_str][y_str]) { | |
| const z = parseInt(z_str, 10); | |
| const type = chunk.blocks[x_str][y_str][z_str]; | |
| if (type && blockTypes[type] && blockGeometries[type] && blockMaterials[type]) { | |
| const wx = chunk.x * CHUNK_SIZE + x; | |
| const wz = chunk.z * CHUNK_SIZE + z; | |
| dummy.position.set(wx * blockSize, y * blockHeight, wz * blockSize); | |
| dummy.updateMatrix(); | |
| instances[type].push(dummy.matrix.clone()); | |
| } | |
| } | |
| } | |
| } | |
| for (const type in instances) { | |
| const data = instances[type]; | |
| if (data.length > 0) { | |
| const mesh = new THREE.InstancedMesh(blockGeometries[type], blockMaterials[type], data.length); | |
| for (let i = 0; i < data.length; i++) { mesh.setMatrixAt(i, data[i]); } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| if (blockTypes[type]?.solid) { mesh.castShadow = true; mesh.receiveShadow = true; } | |
| chunk.instancedMeshes[type] = mesh; | |
| scene.add(mesh); | |
| } | |
| } | |
| } | |
| function clientSetBlock(wx, wy, wz, type) { | |
| const chunkKey = getChunkKey(wx, wz); | |
| const chunk = chunks.get(chunkKey); | |
| if (!chunk || !chunk.blocks) return false; | |
| const lx = ((wx % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| const lz = ((wz % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE; | |
| if (!chunk.blocks[lx]) chunk.blocks[lx] = {}; | |
| if (!chunk.blocks[lx][wy]) chunk.blocks[lx][wy] = {}; | |
| const oldType = chunk.blocks[lx][wy][lz]; | |
| if (type === null) { | |
| if (lz in chunk.blocks[lx][wy]) { | |
| delete chunk.blocks[lx][wy][lz]; | |
| if (Object.keys(chunk.blocks[lx][wy]).length === 0) { | |
| delete chunk.blocks[lx][wy]; | |
| if (Object.keys(chunk.blocks[lx]).length === 0) delete chunk.blocks[lx]; | |
| } | |
| } | |
| } else { | |
| chunk.blocks[lx][wy][lz] = type; | |
| chunk.maxHeight = Math.max(chunk.maxHeight, wy); | |
| } | |
| if (oldType !== type) { | |
| updateChunkMesh(chunk); | |
| return true; | |
| } | |
| return false; | |
| } | |
| let lastPlayerChunkKey = ''; | |
| let requestedChunks = new Set(); | |
| function updateVisibleChunks() { | |
| if (!player || !player.position) return; | |
| const playerChunkX = Math.floor(player.position.x / blockSize / CHUNK_SIZE); | |
| const playerChunkZ = Math.floor(player.position.z / blockSize / CHUNK_SIZE); | |
| const visibleChunksKeys = new Set(); | |
| for (let x = playerChunkX - RENDER_DISTANCE; x <= playerChunkX + RENDER_DISTANCE; x++) { | |
| for (let z = playerChunkZ - RENDER_DISTANCE; z <= playerChunkZ + RENDER_DISTANCE; z++) { | |
| const key = `${x},${z}`; | |
| visibleChunksKeys.add(key); | |
| if (!chunks.has(key) && !requestedChunks.has(key)) { | |
| requestedChunks.add(key); | |
| socket.emit('request_chunk', { chunkX: x, chunkZ: z }); | |
| } | |
| } | |
| } | |
| const chunksToRemove = []; | |
| for (const key of chunks.keys()) { | |
| if (!visibleChunksKeys.has(key)) chunksToRemove.push(key); | |
| } | |
| for (const key of chunksToRemove) { | |
| const chunk = chunks.get(key); | |
| for (const type in chunk.instancedMeshes) { | |
| const mesh = chunk.instancedMeshes[type]; | |
| scene.remove(mesh); | |
| mesh.geometry.dispose(); | |
| if (mesh.material.map) mesh.material.map.dispose(); | |
| mesh.material.dispose(); | |
| } | |
| chunks.delete(key); | |
| } | |
| } | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(0, 0); | |
| function onPointerDown(event) { | |
| if (document.pointerLockElement !== renderer.domElement) return; | |
| if (event.target.tagName !== 'CANVAS') return; | |
| event.preventDefault(); | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObjects(scene.children.filter(c => c.isInstancedMesh), false); | |
| if (intersects.length > 0) { | |
| const intersection = intersects[0]; | |
| const instanceMatrix = new THREE.Matrix4(); | |
| intersection.object.getMatrixAt(intersection.instanceId, instanceMatrix); | |
| const worldPos = new THREE.Vector3().setFromMatrixPosition(instanceMatrix); | |
| const clickedBlockGridX = Math.round(worldPos.x / blockSize); | |
| const clickedBlockGridY = Math.round(worldPos.y / blockHeight); | |
| const clickedBlockGridZ = Math.round(worldPos.z / blockSize); | |
| let targetBlockGridX, targetBlockGridY, targetBlockGridZ; | |
| let blockTypeToSend; | |
| if (currentBlockType === 'remove') { | |
| targetBlockGridX = clickedBlockGridX; | |
| targetBlockGridY = clickedBlockGridY; | |
| targetBlockGridZ = clickedBlockGridZ; | |
| blockTypeToSend = null; | |
| } else { | |
| const placePos = worldPos.clone().add(intersection.face.normal.multiplyScalar(0.5)); | |
| targetBlockGridX = Math.round(placePos.x / blockSize); | |
| targetBlockGridY = Math.round(placePos.y / blockHeight); | |
| targetBlockGridZ = Math.round(placePos.z / blockSize); | |
| blockTypeToSend = currentBlockType; | |
| } | |
| socket.emit('send_block_change', { | |
| wx: targetBlockGridX, wy: targetBlockGridY, wz: targetBlockGridZ, | |
| type: blockTypeToSend | |
| }); | |
| } | |
| } | |
| document.addEventListener('keydown', (event) => keys[event.code] = true); | |
| document.addEventListener('keyup', (event) => keys[event.code] = false); | |
| window.addEventListener('mousedown', onPointerDown); | |
| function updateToolbarListeners() { | |
| document.querySelectorAll('.block-button').forEach(button => { | |
| if (button.dataset.listenerAttached) return; | |
| button.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| if (button.id === 'add-custom-block') { | |
| document.getElementById('modal-backdrop').style.display = 'block'; | |
| document.getElementById('custom-block-modal').style.display = 'block'; | |
| return; | |
| } | |
| document.querySelectorAll('.block-button').forEach(btn => btn.classList.remove('selected')); | |
| button.classList.add('selected'); | |
| currentBlockType = button.id; | |
| }); | |
| button.dataset.listenerAttached = true; | |
| }); | |
| } | |
| updateToolbarListeners(); | |
| const isTouchDevice = 'ontouchstart' in window; | |
| const joystick = { | |
| active: false, | |
| startPos: { x: 0, y: 0 }, | |
| center: { x: 75, y: 75 }, | |
| radius: 75, | |
| touchId: null | |
| }; | |
| const cameraTouch = { | |
| active: false, | |
| startPos: { x: 0, y: 0 }, | |
| touchId: null | |
| }; | |
| if (isTouchDevice) { | |
| const joystickContainer = document.getElementById('joystick-container'); | |
| const joystickHandle = document.getElementById('joystick-handle'); | |
| joystickContainer.style.display = 'block'; | |
| gameContainer.addEventListener('touchstart', (e) => { | |
| e.preventDefault(); | |
| for (let i = 0; i < e.changedTouches.length; i++) { | |
| const touch = e.changedTouches[i]; | |
| const target = touch.target; | |
| if ((target === joystickContainer || target === joystickHandle.parentNode) && joystick.touchId === null) { | |
| joystick.active = true; | |
| joystick.touchId = touch.identifier; | |
| joystick.startPos = { x: touch.clientX, y: touch.clientY }; | |
| } else if (cameraTouch.touchId === null) { | |
| cameraTouch.active = true; | |
| cameraTouch.touchId = touch.identifier; | |
| cameraTouch.startPos = { x: touch.clientX, y: touch.clientY }; | |
| } | |
| } | |
| }, { passive: false }); | |
| gameContainer.addEventListener('touchmove', (e) => { | |
| e.preventDefault(); | |
| for (let i = 0; i < e.changedTouches.length; i++) { | |
| const touch = e.changedTouches[i]; | |
| if (joystick.active && touch.identifier === joystick.touchId) { | |
| let dx = touch.clientX - joystick.startPos.x; | |
| let dy = touch.clientY - joystick.startPos.y; | |
| const distance = Math.sqrt(dx * dx + dy * dy); | |
| const angle = Math.atan2(dy, dx); | |
| const handleDist = Math.min(distance, joystick.radius / 2); | |
| joystickHandle.style.transform = `translate(${handleDist * Math.cos(angle)}px, ${handleDist * Math.sin(angle)}px)`; | |
| playerMoveDirection.set(dx, 0, dy).normalize(); | |
| } else if (cameraTouch.active && touch.identifier === cameraTouch.touchId) { | |
| const deltaX = touch.clientX - cameraTouch.startPos.x; | |
| const deltaY = touch.clientY - cameraTouch.startPos.y; | |
| cameraTouch.startPos = { x: touch.clientX, y: touch.clientY }; | |
| player.rotation.y -= deltaX * 0.002; | |
| cameraPivot.rotation.x -= deltaY * 0.002; | |
| cameraPivot.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, cameraPivot.rotation.x)); | |
| } | |
| } | |
| }, { passive: false }); | |
| const onTouchEnd = (e) => { | |
| for (let i = 0; i < e.changedTouches.length; i++) { | |
| const touch = e.changedTouches[i]; | |
| if (touch.identifier === joystick.touchId) { | |
| joystick.active = false; | |
| joystick.touchId = null; | |
| joystickHandle.style.transform = `translate(0px, 0px)`; | |
| playerMoveDirection.set(0, 0, 0); | |
| } else if (touch.identifier === cameraTouch.touchId) { | |
| cameraTouch.active = false; | |
| cameraTouch.touchId = null; | |
| } | |
| } | |
| }; | |
| gameContainer.addEventListener('touchend', onTouchEnd); | |
| gameContainer.addEventListener('touchcancel', onTouchEnd); | |
| } else { | |
| gameContainer.addEventListener('click', () => { | |
| if (document.pointerLockElement !== renderer.domElement) { | |
| renderer.domElement.requestPointerLock(); | |
| } | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| keys.enabled = document.pointerLockElement === renderer.domElement; | |
| }); | |
| document.addEventListener('mousemove', (event) => { | |
| if (document.pointerLockElement !== renderer.domElement) return; | |
| player.rotation.y -= event.movementX * 0.002; | |
| cameraPivot.rotation.x -= event.movementY * 0.002; | |
| cameraPivot.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, cameraPivot.rotation.x)); | |
| }); | |
| } | |
| function updateCameraFrustum() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| } | |
| document.getElementById('modal-backdrop').addEventListener('click', () => { | |
| document.getElementById('modal-backdrop').style.display = 'none'; | |
| document.getElementById('custom-block-modal').style.display = 'none'; | |
| }); | |
| document.getElementById('create-block-btn').addEventListener('click', () => { | |
| const nameInput = document.getElementById('block-name'); | |
| const fileInput = document.getElementById('texture-upload'); | |
| const solidCheckbox = document.getElementById('block-solid'); | |
| const name = nameInput.value.trim().replace(/[^a-zA-Z0-9_]/g, ''); | |
| const file = fileInput.files[0]; | |
| if (!name || !file || blockTypes[name]) { | |
| alert('Пожалуйста, введите уникальное имя (только латинские буквы, цифры, _) и выберите PNG файл.'); | |
| return; | |
| } | |
| const imageUrl = URL.createObjectURL(file); | |
| textureLoader.load(imageUrl, (newTexture) => { | |
| URL.revokeObjectURL(imageUrl); | |
| newTexture.magFilter = THREE.NearestFilter; | |
| blockTypes[name] = { solid: solidCheckbox.checked, texture: newTexture }; | |
| blockGeometries[name] = new THREE.BoxGeometry(blockSize, blockHeight, blockSize); | |
| if (blockTypes[name].solid) { | |
| blockMaterials[name] = new THREE.MeshStandardMaterial({ map: newTexture, roughness: 0.8, metalness: 0.1 }); | |
| } else { | |
| blockMaterials[name] = new THREE.MeshLambertMaterial({ map: newTexture, transparent: true, opacity: 0.8 }); | |
| blockMaterials[name].alphaTest = 0.01; | |
| } | |
| const newButton = document.createElement('div'); | |
| newButton.className = 'block-button'; | |
| newButton.id = name; newButton.title = name; | |
| newButton.style.backgroundImage = `url(${imageUrl})`; | |
| const addButton = document.getElementById('add-custom-block'); | |
| document.getElementById('toolbar').insertBefore(newButton, addButton); | |
| updateToolbarListeners(); | |
| newButton.click(); | |
| document.getElementById('modal-backdrop').style.display = 'none'; | |
| document.getElementById('custom-block-modal').style.display = 'none'; | |
| nameInput.value = ''; fileInput.value = ''; | |
| }); | |
| }); | |
| document.getElementById('face-upload').addEventListener('change', (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const imageUrl = URL.createObjectURL(file); | |
| textureLoader.load(imageUrl, (newFaceTexture) => { | |
| URL.revokeObjectURL(imageUrl); | |
| newFaceTexture.magFilter = THREE.NearestFilter; | |
| player.userData.faceMaterial.map = newFaceTexture; | |
| player.userData.faceMaterial.needsUpdate = true; | |
| }); | |
| }); | |
| document.getElementById('join-button').addEventListener('click', () => { | |
| const nickname = document.getElementById('nickname-input').value.trim(); | |
| if (nickname) { | |
| document.getElementById('nickname-modal').style.display = 'none'; | |
| socket.emit('join', { nickname: nickname }); | |
| } else { | |
| alert('Пожалуйста, введите никнейм.'); | |
| } | |
| }); | |
| window.addEventListener('resize', () => { | |
| updateCameraFrustum(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| composer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| function checkCollision(pos) { | |
| const playerGridX = Math.floor(pos.x / blockSize); | |
| const playerGridY = Math.floor((pos.y - 0.1) / blockHeight); | |
| const playerGridZ = Math.floor(pos.z / blockSize); | |
| return isBlockSolidClient(playerGridX, playerGridY, playerGridZ) || isBlockSolidClient(playerGridX, playerGridY+1, playerGridZ); | |
| } | |
| const clock = new THREE.Clock(); | |
| let lastStateUpdateTime = 0; | |
| const stateUpdateInterval = 1000 / 30; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = Math.min(clock.getDelta(), 0.1); | |
| const time = clock.getElapsedTime(); | |
| TWEEN.update(); | |
| updateVisibleChunks(); | |
| if (!isTouchDevice) { | |
| playerMoveDirection.set(0, 0, 0); | |
| if (keys['KeyW'] || keys['ArrowUp']) playerMoveDirection.z = -1; | |
| if (keys['KeyS'] || keys['ArrowDown']) playerMoveDirection.z = 1; | |
| if (keys['KeyA'] || keys['ArrowLeft']) playerMoveDirection.x = -1; | |
| if (keys['KeyD'] || keys['ArrowRight']) playerMoveDirection.x = 1; | |
| playerMoveDirection.normalize(); | |
| } | |
| const isMoving = playerMoveDirection.lengthSq() > 0.01; | |
| const damping = Math.exp(-4 * delta) - 1; | |
| playerVelocity.y += gravity * delta; | |
| let moveVector = playerMoveDirection.clone().applyEuler(player.rotation).multiplyScalar(playerSpeed); | |
| playerVelocity.x += (moveVector.x - playerVelocity.x) * (1-Math.exp(-15*delta)); | |
| playerVelocity.z += (moveVector.z - playerVelocity.z) * (1-Math.exp(-15*delta)); | |
| let deltaPosition = playerVelocity.clone().multiplyScalar(delta); | |
| let playerOnGround = false; | |
| const halfWidth = playerWidth / 2; | |
| const halfHeight = playerHeight / 2; | |
| const yCheckPos = player.position.y + deltaPosition.y; | |
| const feetGridY = Math.floor((yCheckPos - halfHeight) / blockHeight); | |
| const checkPointsY = [ | |
| {x: player.position.x, z: player.position.z}, | |
| {x: player.position.x + halfWidth, z: player.position.z + halfWidth}, | |
| {x: player.position.x - halfWidth, z: player.position.z + halfWidth}, | |
| {x: player.position.x + halfWidth, z: player.position.z - halfWidth}, | |
| {x: player.position.x - halfWidth, z: player.position.z - halfWidth}, | |
| ]; | |
| let groundCollision = false; | |
| for(const p of checkPointsY) { | |
| if (isBlockSolidClient(Math.floor(p.x/blockSize), feetGridY, Math.floor(p.z/blockSize))) { | |
| groundCollision = true; break; | |
| } | |
| } | |
| if(groundCollision) { | |
| playerVelocity.y = Math.max(0, playerVelocity.y); | |
| deltaPosition.y = 0; | |
| playerOnGround = true; | |
| player.position.y = (feetGridY + 1) * blockHeight + halfHeight; | |
| } | |
| player.position.add(deltaPosition); | |
| if (isMoving) { | |
| const angle = Math.atan2(playerVelocity.x, playerVelocity.z); | |
| player.children[0].rotation.y = angle; | |
| const swing = Math.sin(time * 15) * 0.7; | |
| player.userData.leftArm.rotation.x = swing; | |
| player.userData.rightArm.rotation.x = -swing; | |
| player.userData.leftLeg.rotation.x = -swing; | |
| player.userData.rightLeg.rotation.x = swing; | |
| } else { | |
| player.userData.leftArm.rotation.x = 0; | |
| player.userData.rightArm.rotation.x = 0; | |
| player.userData.leftLeg.rotation.x = 0; | |
| player.userData.rightLeg.rotation.x = 0; | |
| } | |
| const idealOffset = new THREE.Vector3(0, 1.8, 8); | |
| const idealCamPos = new THREE.Vector3().copy(idealOffset); | |
| idealCamPos.applyQuaternion(cameraPivot.quaternion); | |
| idealCamPos.applyQuaternion(player.quaternion); | |
| idealCamPos.add(player.position); | |
| const camRay = new THREE.Raycaster(player.position, idealCamPos.clone().sub(player.position).normalize(), 0, idealOffset.length()); | |
| const camIntersects = camRay.intersectObjects(scene.children.filter(c => c.isInstancedMesh), false); | |
| if (camIntersects.length > 0) { | |
| camera.position.lerp(camIntersects[0].point.add(camIntersects[0].face.normal.multiplyScalar(0.5)), 0.2); | |
| } else { | |
| camera.position.lerp(idealCamPos, 0.1); | |
| } | |
| for (const sid in otherPlayers) { | |
| const otherPlayer = otherPlayers[sid]; | |
| otherPlayer.mesh.position.lerp(otherPlayer.targetPos, 0.15); | |
| otherPlayer.mesh.rotation.y = THREE.MathUtils.lerp(otherPlayer.mesh.rotation.y, otherPlayer.targetRot.y, 0.15); | |
| const otherIsMoving = otherPlayer.mesh.position.distanceTo(otherPlayer.targetPos) > 0.1; | |
| const otherAnimSpeed = 15; | |
| if (otherIsMoving) { | |
| const swing = Math.sin(time * otherAnimSpeed + sid.charCodeAt(0)) * 0.7; | |
| otherPlayer.mesh.userData.leftArm.rotation.x = swing; | |
| otherPlayer.mesh.userData.rightArm.rotation.x = -swing; | |
| otherPlayer.mesh.userData.leftLeg.rotation.x = -swing; | |
| otherPlayer.mesh.userData.rightLeg.rotation.x = swing; | |
| } else { | |
| otherPlayer.mesh.userData.leftArm.rotation.x = 0; | |
| otherPlayer.mesh.userData.rightArm.rotation.x = 0; | |
| otherPlayer.mesh.userData.leftLeg.rotation.x = 0; | |
| otherPlayer.mesh.userData.rightLeg.rotation.x = 0; | |
| } | |
| const headWorldPos = otherPlayer.mesh.userData.head.getWorldPosition(new THREE.Vector3()); | |
| const screenPosition = headWorldPos.project(camera); | |
| const posX = (screenPosition.x * 0.5 + 0.5) * window.innerWidth; | |
| const posY = (-screenPosition.y * 0.5 + 0.5) * window.innerHeight; | |
| otherPlayer.label.style.left = `${posX}px`; | |
| otherPlayer.label.style.top = `${posY}px`; | |
| otherPlayer.label.style.display = screenPosition.z > 0 && screenPosition.z < 1 ? 'block' : 'none'; | |
| } | |
| const now = performance.now(); | |
| if ((isMoving || now - lastStateUpdateTime > stateUpdateInterval) && localPlayerId) { | |
| if (player.lastSentPosition === undefined || player.position.distanceTo(player.lastSentPosition) > 0.05 || Math.abs(player.rotation.y - player.lastSentRotationY) > 0.05) { | |
| socket.emit('send_player_state', { | |
| position: player.position, rotationY: player.rotation.y | |
| }); | |
| player.lastSentPosition = player.position.clone(); | |
| player.lastSentRotationY = player.rotation.y; | |
| lastStateUpdateTime = now; | |
| } | |
| } | |
| if(blockMaterials.sea && blockMaterials.sea.map) { | |
| blockMaterials.sea.map.offset.x = time * 0.02; | |
| blockMaterials.sea.map.offset.y = time * 0.01; | |
| blockMaterials.sea.map.wrapS = THREE.RepeatWrapping; | |
| blockMaterials.sea.map.wrapT = THREE.RepeatWrapping; | |
| } | |
| composer.render(); | |
| } | |
| socket.on('connect', () => {}); | |
| socket.on('disconnect', () => { | |
| for(const sid in otherPlayers) { | |
| scene.remove(otherPlayers[sid].mesh); | |
| otherPlayers[sid].label.remove(); | |
| } | |
| for (const key of chunks.keys()) { | |
| const chunk = chunks.get(key); | |
| for (const type in chunk.instancedMeshes) { | |
| const mesh = chunk.instancedMeshes[type]; | |
| scene.remove(mesh); | |
| mesh.geometry.dispose(); | |
| if (mesh.material.map) mesh.material.map.dispose(); | |
| mesh.material.dispose(); | |
| } | |
| } | |
| otherPlayers = {}; chunks.clear(); requestedChunks.clear(); localPlayerId = null; | |
| document.getElementById('nickname-modal').style.display = 'flex'; | |
| }); | |
| socket.on('player_joined', (data) => { | |
| if (data.sid === socket.id) { | |
| localPlayerId = data.sid; | |
| player.position.copy(data.position); | |
| player.rotation.y = data.rotationY; | |
| } else { | |
| const otherPlayerMesh = createPlayerAvatar(); | |
| otherPlayerMesh.position.copy(data.position); | |
| otherPlayerMesh.rotation.y = data.rotationY; | |
| scene.add(otherPlayerMesh); | |
| const nameLabel = document.createElement('div'); | |
| nameLabel.className = 'player-name-label'; | |
| nameLabel.textContent = data.nickname; | |
| document.body.appendChild(nameLabel); | |
| otherPlayers[data.sid] = { | |
| mesh: otherPlayerMesh, label: nameLabel, nickname: data.nickname, | |
| targetPos: new THREE.Vector3().copy(data.position), | |
| targetRot: new THREE.Euler(0, data.rotationY, 0) | |
| }; | |
| } | |
| }); | |
| socket.on('player_left', (data) => { | |
| if (otherPlayers[data.sid]) { | |
| scene.remove(otherPlayers[data.sid].mesh); | |
| otherPlayers[data.sid].label.remove(); | |
| delete otherPlayers[data.sid]; | |
| } | |
| }); | |
| socket.on('current_players', (data) => { | |
| for (const sid in data) { | |
| if (sid !== socket.id) { | |
| const playerData = data[sid]; | |
| const otherPlayerMesh = createPlayerAvatar(); | |
| otherPlayerMesh.position.copy(playerData.position); | |
| otherPlayerMesh.rotation.y = playerData.rotationY; | |
| scene.add(otherPlayerMesh); | |
| const nameLabel = document.createElement('div'); | |
| nameLabel.className = 'player-name-label'; | |
| nameLabel.textContent = playerData.nickname; | |
| document.body.appendChild(nameLabel); | |
| otherPlayers[sid] = { | |
| mesh: otherPlayerMesh, label: nameLabel, nickname: playerData.nickname, | |
| targetPos: new THREE.Vector3().copy(playerData.position), | |
| targetRot: new THREE.Euler(0, playerData.rotationY, 0) | |
| }; | |
| } | |
| } | |
| }); | |
| socket.on('player_state_update', (data) => { | |
| if (otherPlayers[data.sid]) { | |
| otherPlayers[data.sid].targetPos.copy(data.position); | |
| otherPlayers[data.sid].targetRot.set(0, data.rotationY, 0); | |
| } | |
| }); | |
| socket.on('initial_world_data', (data) => { | |
| for (const chunkKey in data) { | |
| const chunkData = data[chunkKey]; | |
| const [cx, cz] = chunkKey.split(',').map(Number); | |
| chunks.set(chunkKey, { | |
| key: chunkKey, blocks: chunkData.blocks || {}, maxHeight: chunkData.maxHeight || 0, | |
| instancedMeshes: {}, x: cx, z: cz | |
| }); | |
| updateChunkMesh(chunks.get(chunkKey)); | |
| requestedChunks.delete(chunkKey); | |
| } | |
| }); | |
| socket.on('chunk_data', (data) => { | |
| const chunkKey = data.chunkKey; | |
| const chunkData = data.chunkData; | |
| if (!chunks.has(chunkKey)) { | |
| const [cx, cz] = chunkKey.split(',').map(Number); | |
| chunks.set(chunkKey, { | |
| key: chunkKey, blocks: chunkData.blocks || {}, maxHeight: chunkData.maxHeight || 0, | |
| instancedMeshes: {}, x: cx, z: cz | |
| }); | |
| updateChunkMesh(chunks.get(chunkKey)); | |
| } | |
| requestedChunks.delete(chunkKey); | |
| }); | |
| socket.on('block_changed', (data) => { | |
| clientSetBlock(data.wx, data.wy, data.wz, data.type); | |
| }); | |
| document.getElementById('grass').classList.add('selected'); | |
| updateCameraFrustum(); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def index(): | |
| return Response(html_content, mimetype='text/html') | |
| if __name__ == '__main__': | |
| load_world() | |
| atexit.register(save_world) | |
| if not server_world: | |
| for x in range(-1, 2): | |
| for z in range(-1, 2): | |
| generate_chunk_server(x, z) | |
| port = int(os.environ.get('PORT', 7860)) | |
| socketio.run(app, host='0.0.0.0', port=port, debug=False, allow_unsafe_werkzeug=True) |