Загрузка данных
// ============================================================
// ПВО — защита склада Яндекса. Three.js
// ============================================================
(() => {
"use strict";
// ---------- Константы ----------
const MAX_LIVES = 10;
const MAX_HITS_FOR_LOSE = 10; // 10 попаданий = проигрыш
const LEVEL_DRONES = (lvl) => 5 + lvl * 5; // 10, 15, 20, ...
const DRONE_SPEED_BASE = 9;
const PROJECTILE_SPEED = 160;
const FIRE_COOLDOWN = 0.28;
// ---------- DOM ----------
const elLives = document.getElementById("lives");
const elLevel = document.getElementById("level");
const elKilled = document.getElementById("killed");
const elTotal = document.getElementById("total");
const elHits = document.getElementById("hits");
const overlay = document.getElementById("overlay");
const ovTitle = document.getElementById("ov-title");
const ovText = document.getElementById("ov-text");
const ovBtn = document.getElementById("ov-btn");
// ---------- Сцена ----------
const scene = new THREE.Scene();
// Летний вечер: тёплый градиент неба
const skyColorTop = new THREE.Color(0x2b3a6b);
const skyColorBottom = new THREE.Color(0xff9a5c);
scene.background = skyColorBottom.clone();
scene.fog = new THREE.Fog(0xd67a55, 120, 420);
const camera = new THREE.PerspectiveCamera(65, innerWidth / innerHeight, 0.1, 2000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
// ---------- Свет ----------
const hemi = new THREE.HemisphereLight(0xffc79a, 0x2b1a3a, 0.85);
scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffb070, 1.15);
sun.position.set(-90, 120, 60);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -160;
sun.shadow.camera.right = 160;
sun.shadow.camera.top = 160;
sun.shadow.camera.bottom = -160;
sun.shadow.camera.far = 400;
scene.add(sun);
const ambient = new THREE.AmbientLight(0x8899cc, 0.35);
scene.add(ambient);
// ---------- Земля ----------
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(2000, 2000),
new THREE.MeshStandardMaterial({ color: 0x3d4a35, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.01;
ground.receiveShadow = true;
scene.add(ground);
// ---------- Склад Яндекс ----------
const warehouse = new THREE.Group();
const WAREHOUSE_W = 60, WAREHOUSE_H = 18, WAREHOUSE_D = 40;
const roofY = WAREHOUSE_H;
// Корпус
const bodyMat = new THREE.MeshStandardMaterial({ color: 0xd9dde6, roughness: 0.8 });
const body = new THREE.Mesh(
new THREE.BoxGeometry(WAREHOUSE_W, WAREHOUSE_H, WAREHOUSE_D),
bodyMat
);
body.position.y = WAREHOUSE_H / 2;
body.castShadow = true;
body.receiveShadow = true;
warehouse.add(body);
// Крыша (плоская, чуть выступает)
const roof = new THREE.Mesh(
new THREE.BoxGeometry(WAREHOUSE_W + 2, 1.2, WAREHOUSE_D + 2),
new THREE.MeshStandardMaterial({ color: 0x7a8494, roughness: 0.85 })
);
roof.position.y = WAREHOUSE_H + 0.6;
roof.castShadow = true;
roof.receiveShadow = true;
warehouse.add(roof);
// Логотип "ЯНДЕКС" на фасаде (canvas-текстура)
function makeLabelTexture(text, bg = "#ffffff", fg = "#000000") {
const c = document.createElement("canvas");
c.width = 1024; c.height = 256;
const ctx = c.getContext("2d");
ctx.fillStyle = bg;
ctx.fillRect(0, 0, c.width, c.height);
ctx.fillStyle = fg;
ctx.font = "bold 180px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(text, c.width / 2, c.height / 2 + 10);
const tex = new THREE.CanvasTexture(c);
tex.anisotropy = 8;
return tex;
}
const labelTex = makeLabelTexture("ЯНДЕКС", "#ffffff", "#ff0000");
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: false });
const label = new THREE.Mesh(new THREE.PlaneGeometry(40, 10), labelMat);
label.position.set(0, WAREHOUSE_H * 0.6, WAREHOUSE_D / 2 + 0.05);
warehouse.add(label);
// Боковая надпись
const label2 = new THREE.Mesh(new THREE.PlaneGeometry(28, 7), labelMat);
label2.position.set(WAREHOUSE_W / 2 + 0.05, WAREHOUSE_H * 0.6, 0);
label2.rotation.y = Math.PI / 2;
warehouse.add(label2);
warehouse.position.set(0, 0, 0);
scene.add(warehouse);
// ---------- Зенитная установка ПВО ----------
const turret = new THREE.Group();
turret.position.set(0, roofY + 1.4, 0);
scene.add(turret);
// База
const baseGeo = new THREE.CylinderGeometry(3.2, 3.8, 1.6, 24);
const baseMat = new THREE.MeshStandardMaterial({ color: 0x3a4a2f, roughness: 0.7, metalness: 0.3 });
const baseMesh = new THREE.Mesh(baseGeo, baseMat);
baseMesh.castShadow = true;
baseMesh.receiveShadow = true;
turret.add(baseMesh);
// Вращающаяся часть (голова)
const turretHead = new THREE.Group();
turretHead.position.y = 1.2;
turret.add(turretHead);
// Корпус головы
const headMat = new THREE.MeshStandardMaterial({ color: 0x4b5a3a, roughness: 0.65, metalness: 0.35 });
const headMesh = new THREE.Mesh(new THREE.BoxGeometry(3.4, 2.2, 3.4), headMat);
headMesh.castShadow = true;
turretHead.add(headMesh);
// Ствол (две пушки)
const barrelMat = new THREE.MeshStandardMaterial({ color: 0x222622, roughness: 0.45, metalness: 0.7 });
const barrelGeo = new THREE.CylinderGeometry(0.22, 0.28, 6, 16);
for (const dx of [-0.7, 0.7]) {
const barrel = new THREE.Mesh(barrelGeo, barrelMat);
barrel.rotation.x = Math.PI / 2;
barrel.position.set(dx, 0.4, 2.6);
barrel.castShadow = true;
turretHead.add(barrel);
}
// Надпись "ПВО" на стволе
const pvoTex = makeLabelTexture("ПВО", "#b81d1d", "#ffffff");
const pvoMat = new THREE.MeshBasicMaterial({ map: pvoTex });
const pvoSide = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.6), pvoMat);
pvoSide.position.set(0, 1.25, 1.3);
turretHead.add(pvoSide);
const pvoBack = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.6), pvoMat);
pvoBack.position.set(0, 1.25, -1.3);
pvoBack.rotation.y = Math.PI;
turretHead.add(pvoBack);
// Маленькая красная лампочка
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.18, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xff2200 })
);
lamp.position.set(1.4, 1.2, 0);
turretHead.add(lamp);
// ---------- Огоньки (костры от попаданий) ----------
const fires = [];
function spawnFireOnWarehouse(worldPos) {
const g = new THREE.Group();
// Огонь (конусы)
const fireMat = new THREE.MeshBasicMaterial({ color: 0xff6a00, transparent: true, opacity: 0.9 });
const fireMat2 = new THREE.MeshBasicMaterial({ color: 0xffd23f, transparent: true, opacity: 0.85 });
for (let i = 0; i < 3; i++) {
const h = 1.6 + Math.random() * 1.4;
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.7, h, 8), i === 0 ? fireMat : fireMat2);
cone.position.y = h / 2 + i * 0.1;
cone.position.x = (Math.random() - 0.5) * 0.6;
cone.position.z = (Math.random() - 0.5) * 0.6;
cone.userData.baseY = cone.position.y;
cone.userData.phase = Math.random() * Math.PI * 2;
g.add(cone);
}
// Дым
const smokeMat = new THREE.MeshBasicMaterial({ color: 0x444444, transparent: true, opacity: 0.35 });
for (let i = 0; i < 3; i++) {
const s = new THREE.Mesh(new THREE.SphereGeometry(0.6 + Math.random() * 0.4, 8, 8), smokeMat);
s.position.set((Math.random() - 0.5) * 1.5, 2 + i * 1.2, (Math.random() - 0.5) * 1.5);
s.userData.phase = Math.random() * Math.PI * 2;
g.add(s);
}
// Свет
const light = new THREE.PointLight(0xff7a2a, 1.6, 18, 2);
light.position.y = 1.5;
g.add(light);
g.position.copy(worldPos);
scene.add(g);
fires.push({
group: g,
age: 0,
light,
cones: g.children.filter(c => c.geometry && c.geometry.type === "ConeGeometry"),
smokes: g.children.filter(c => c.geometry && c.geometry.type === "SphereGeometry"),
});
}
function updateFires(dt, t) {
for (const f of fires) {
f.age += dt;
const flick = 0.75 + Math.sin(t * 18 + f.age * 5) * 0.2 + Math.random() * 0.1;
f.light.intensity = 1.2 * flick;
for (const c of f.cones) {
c.scale.y = 0.85 + flick * 0.4;
c.position.y = c.userData.baseY * (0.9 + flick * 0.15);
}
for (const s of f.smokes) {
s.position.y += dt * 0.9;
s.material.opacity = Math.max(0, 0.35 - f.age * 0.02);
if (s.position.y > 8) s.position.y = 2;
}
}
}
// ---------- Дроны ----------
const drones = [];
function createDrone() {
const g = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.5, metalness: 0.6 });
// Фюзеляж
const bodyGeo = new THREE.CylinderGeometry(0.4, 0.9, 4.2, 10);
const bodyM = new THREE.Mesh(bodyGeo, mat);
bodyM.rotation.x = Math.PI / 2;
bodyM.castShadow = true;
g.add(bodyM);
// Крылья (дельта)
const wingGeo = new THREE.BufferGeometry();
const verts = new Float32Array([
0, 0, 1.4,
-4, 0, -1.8,
-1.2, 0, -1.8,
0, 0, 1.4,
1.2, 0, -1.8,
4, 0, -1.8,
]);
wingGeo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
wingGeo.computeVertexNormals();
const wings = new THREE.Mesh(wingGeo, mat);
wings.castShadow = true;
g.add(wings);
// Хвост
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.9, 1.2), mat);
tail.position.set(0, 0.5, -1.7);
g.add(tail);
// Красный огонёк
const blink = new THREE.Mesh(
new THREE.SphereGeometry(0.15, 8, 8),
new THREE.MeshBasicMaterial({ color: 0xff0000 })
);
blink.position.set(0, 0, -1.9);
g.add(blink);
g.userData.blink = blink;
// Точка попадания (для рейкаста — невидимая сфера-обёртка)
const hitGeo = new THREE.SphereGeometry(2.4, 8, 8);
const hitMat = new THREE.MeshBasicMaterial({ visible: false });
const hitBox = new THREE.Mesh(hitGeo, hitMat);
hitBox.userData.isDroneHit = true;
g.add(hitBox);
g.userData.hitBox = hitBox;
return g;
}
function spawnDrone(level) {
const g = createDrone();
// Случайная точка спавна на краю карты
const angle = Math.random() * Math.PI * 2;
const radius = 320 + Math.random() * 60;
const sx = Math.cos(angle) * radius;
const sz = Math.sin(angle) * radius;
const sy = 55 + Math.random() * 45;
g.position.set(sx, sy, sz);
// Цель — случайная точка на крыше склада
const tx = (Math.random() - 0.5) * (WAREHOUSE_W - 6);
const tz = (Math.random() - 0.5) * (WAREHOUSE_D - 6);
const target = new THREE.Vector3(tx, roofY + 0.8, tz);
const dir = target.clone().sub(g.position).normalize();
g.lookAt(target);
const speed = DRONE_SPEED_BASE + level * 1.4 + Math.random() * 2;
drones.push({
mesh: g,
velocity: dir.multiplyScalar(speed),
target,
hp: 1,
speed,
wobble: Math.random() * Math.PI * 2,
hitBox: g.userData.hitBox,
});
scene.add(g);
return g;
}
// ---------- Снаряды ----------
const projectiles = [];
const projGeo = new THREE.SphereGeometry(0.35, 8, 8);
const projMat = new THREE.MeshBasicMaterial({ color: 0xffdd44 });
const projTrailMat = new THREE.MeshBasicMaterial({ color: 0xff6600, transparent: true, opacity: 0.6 });
function shoot() {
if (state.fireTimer > 0 || state.phase !== "play") return;
state.fireTimer = FIRE_COOLDOWN;
// Направление — из камеры (прицел)
const dir = new THREE.Vector3();
camera.getWorldDirection(dir);
// Стартовая точка — приблизительно из ствола
const muzzleWorld = new THREE.Vector3();
turretHead.localToWorld(muzzleWorld.set(0, 0.4, 3));
// Но стреляем в точку перед камерой, чтоб визуально попадало в прицел
const origin = camera.position.clone().add(dir.clone().multiplyScalar(2.5));
const p = new THREE.Mesh(projGeo, projMat);
p.position.copy(origin);
p.scale.setScalar(1);
scene.add(p);
// Вспышка у ствола
spawnMuzzleFlash(muzzleWorld);
projectiles.push({
mesh: p,
velocity: dir.clone().multiplyScalar(PROJECTILE_SPEED),
life: 0,
maxLife: 3,
});
// Звук выстрела (Web Audio — простой синтез)
playShootSound();
}
function spawnMuzzleFlash(pos) {
const flash = new THREE.Mesh(
new THREE.SphereGeometry(0.6, 8, 8),
new THREE.MeshBasicMaterial({ color: 0xffcc33, transparent: true, opacity: 0.9 })
);
flash.position.copy(pos);
scene.add(flash);
let t = 0;
const anim = () => {
t += 0.06;
flash.scale.setScalar(1 + t * 3);
flash.material.opacity = 0.9 * (1 - t);
if (t < 1) requestAnimationFrame(anim);
else scene.remove(flash);
};
anim();
}
// ---------- Взрывы ----------
const explosions = [];
function spawnExplosion(pos, big = false) {
const count = big ? 40 : 20;
const geo = new THREE.SphereGeometry(big ? 0.6 : 0.35, 6, 6);
const group = new THREE.Group();
const particles = [];
for (let i = 0; i < count; i++) {
const m = new THREE.Mesh(
geo,
new THREE.MeshBasicMaterial({
color: i % 2 === 0 ? 0xffcc33 : 0xff5500,
transparent: true,
opacity: 1,
})
);
const dir = new THREE.Vector3(
Math.random() - 0.5,
Math.random() - 0.5,
Math.random() - 0.5
).normalize();
const speed = 6 + Math.random() * (big ? 22 : 12);
m.position.copy(pos);
m.userData.vel = dir.multiplyScalar(speed);
m.userData.life = 0;
m.userData.max = 0.6 + Math.random() * 0.5;
group.add(m);
particles.push(m);
}
scene.add(group);
const light = new THREE.PointLight(0xffaa33, big ? 6 : 3, big ? 40 : 20, 2);
light.position.copy(pos);
group.add(light);
explosions.push({ group, particles, light, age: 0, big });
playExplosionSound(big);
}
function updateExplosions(dt) {
for (let i = explosions.length - 1; i >= 0; i--) {
const e = explosions[i];
e.age += dt;
let alive = false;
for (const p of e.particles) {
p.userData.life += dt;
if (p.userData.life < p.userData.max) {
alive = true;
p.position.addScaledVector(p.userData.vel, dt);
p.userData.vel.multiplyScalar(0.94);
p.material.opacity = 1 - p.userData.life / p.userData.max;
p.scale.multiplyScalar(0.98);
} else {
p.visible = false;
}
}
e.light.intensity = Math.max(0, (e.big ? 6 : 3) * (1 - e.age / 0.5));
if (!alive && e.age > 0.7) {
scene.remove(e.group);
explosions.splice(i, 1);
}
}
}
// ---------- Звук (Web Audio) ----------
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
}
}
function playShootSound() {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(220, t);
osc.frequency.exponentialRampToValueAtTime(60, t + 0.15);
gain.gain.setValueAtTime(0.12, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
osc.connect(gain).connect(audioCtx.destination);
osc.start(t); osc.stop(t + 0.16);
}
function playExplosionSound(big) {
if (!audioCtx) return;
const t = audioCtx.currentTime;
const bufferSize = audioCtx.sampleRate * (big ? 0.6 : 0.35);
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2);
}
const src = audioCtx.createBufferSource();
src.buffer = buffer;
const gain = audioCtx.createGain();
gain.gain.value = big ? 0.4 : 0.25;
const filter = audioCtx.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.value = big ? 900 : 1500;
src.connect(filter).connect(gain).connect(audioCtx.destination);
src.start(t);
}
// ---------- Состояние игры ----------
const state = {
phase: "menu", // menu | play | win | lose
level: 1,
lives: MAX_LIVES,
hits: 0,
killed: 0,
totalToSpawn: 0,
spawned: 0,
fireTimer: 0,
spawnTimer: 0,
spawnInterval: 1.6,
nextLevelDelay: 0,
};
// ---------- Управление камерой (мышь) ----------
const mouse = { x: 0, y: 0 };
const cameraBase = new THREE.Vector3(0, roofY + 12, 60);
const cameraTarget = new THREE.Vector3(0, roofY + 4, 0);
// ---------- Ввод ----------
const keys = {};
addEventListener("keydown", (e) => {
keys[e.code] = true;
if (e.code === "Space") { e.preventDefault(); shoot(); }
if (e.code === "KeyR" && (state.phase === "win" || state.phase === "lose")) resetGame();
});
addEventListener("keyup", (e) => { keys[e.code] = false; });
addEventListener("mousemove", (e) => {
mouse.x = (e.clientX / innerWidth) * 2 - 1;
mouse.y = -((e.clientY / innerHeight) * 2 - 1);
});
addEventListener("mousedown", (e) => {
if (e.button === 0) {
initAudio();
shoot();
}
});
addEventListener("contextmenu", (e) => e.preventDefault());
addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ---------- Логика стрельбы и попаданий ----------
const raycaster = new THREE.Raycaster();
function checkProjectileHits() {
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
const ppos = p.mesh.position;
let hit = false;
// Проверка по дронам
for (let j = drones.length - 1; j >= 0; j--) {
const d = drones[j];
const dp = d.mesh.position;
const dist = ppos.distanceTo(dp);
if (dist < 2.6) {
// Попадание
destroyDrone(j);
hit = true;
break;
}
}
if (hit) {
scene.remove(p.mesh);
projectiles.splice(i, 1);
}
}
}
function destroyDrone(index) {
const d = drones[index];
const pos = d.mesh.position.clone();
spawnExplosion(pos, false);
scene.remove(d.mesh);
drones.splice(index, 1);
state.killed++;
updateHUD();
}
// ---------- Попадание дрона в склад ----------
function droneHitsWarehouse(d) {
const pos = new THREE.Vector3(
(Math.random() - 0.5) * (WAREHOUSE_W - 8),
roofY + 0.7,
(Math.random() - 0.5) * (WAREHOUSE_D - 8)
);
// Ограничим, чтобы огоньки были на крыше
spawnExplosion(pos, true);
spawnFireOnWarehouse(pos);
state.lives--;
state.hits++;
// Тряска камеры
shakeAmount = 0.6;
updateHUD();
if (state.hits >= MAX_HITS_FOR_LOSE || state.lives <= 0) {
endGame(false);
}
}
// ---------- Спавн ----------
function startLevel(level) {
state.level = level;
state.totalToSpawn = LEVEL_DRONES(level);
state.spawned = 0;
state.killed = 0;
state.spawnTimer = 1.5;
state.spawnInterval = Math.max(0.7, 1.8 - level * 0.12);
updateHUD();
}
function updateHUD() {
elLives.textContent = Math.max(0, state.lives);
elLevel.textContent = state.level;
elKilled.textContent = state.killed;
elTotal.textContent = state.totalToSpawn;
elHits.textContent = state.hits;
}
// ---------- Камера-тряска ----------
let shakeAmount = 0;
// ---------- Игровой цикл ----------
const clock = new THREE.Clock();
let timeAcc = 0;
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
timeAcc += dt;
// Таймеры
if (state.fireTimer > 0) state.fireTimer -= dt;
// Камера следует за мышью
const camX = cameraBase.x + mouse.x * 45;
const camY = cameraBase.y + mouse.y * 22;
const camZ = cameraBase.z;
camera.position.x += (camX - camera.position.x) * Math.min(1, dt * 8);
camera.position.y += (camY - camera.position.y) * Math.min(1, dt * 8);
camera.position.z = camZ;
// Тряска
if (shakeAmount > 0) {
camera.position.x += (Math.random() - 0.5) * shakeAmount;
camera.position.y += (Math.random() - 0.5) * shakeAmount;
shakeAmount *= Math.pow(0.001, dt);
if (shakeAmount < 0.02) shakeAmount = 0;
}
camera.lookAt(cameraTarget);
// Поворот башни в сторону прицела
const aimPoint = camera.position.clone().add(
new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).multiplyScalar(80)
);
const localAim = turret.worldToLocal(aimPoint.clone());
const angleY = Math.atan2(localAim.x, localAim.z);
// Плавно
let da = angleY - turretHead.rotation.y;
while (da > Math.PI) da -= Math.PI * 2;
while (da < -Math.PI) da += Math.PI * 2;
turretHead.rotation.y += da * Math.min(1, dt * 10);
// Наклон ствола вверх
const horiz = Math.hypot(localAim.x, localAim.z);
const pitchTarget = Math.atan2(localAim.y, horiz);
turretHead.rotation.x += (pitchTarget * 0.5 - turretHead.rotation.x) * Math.min(1, dt * 6);
// Мигание лампочки и огней дронов
lamp.material.color.setHex(Math.sin(timeAcc * 8) > 0 ? 0xff2200 : 0x550000);
for (const d of drones) {
if (d.mesh.userData.blink) {
d.mesh.userData.blink.material.color.setHex(
Math.sin(timeAcc * 10 + d.wobble) > 0 ? 0xff0000 : 0x330000
);
}
}
// Спавн дронов
if (state.phase === "play" && state.spawned < state.totalToSpawn) {
state.spawnTimer -= dt;
if (state.spawnTimer <= 0) {
spawnDrone(state.level);
state.spawned++;
state.spawnTimer = state.spawnInterval * (0.7 + Math.random() * 0.6);
}
}
// Движение дронов
for (let i = drones.length - 1; i >= 0; i--) {
const d = drones[i];
d.wobble += dt * 3;
const wob = new THREE.Vector3(
Math.sin(d.wobble) * 0.4,
Math.sin(d.wobble * 1.3) * 0.3,
Math.cos(d.wobble * 0.7) * 0.4
);
d.mesh.position.addScaledVector(d.velocity, dt);
d.mesh.position.addScaledVector(wob, dt);
// Лёгкий крен
d.mesh.rotation.z = Math.sin(d.wobble) * 0.15;
// Проверка достижения цели
if (d.mesh.position.distanceTo(d.target) < 4) {
droneHitsWarehouse(d);
scene.remove(d.mesh);
drones.splice(i, 1);
// Если это была последняя цель и всё сбито — уровень пройден
continue;
}
// Если дрон улетел слишком далеко (защита от багов)
if (d.mesh.position.length() > 800) {
scene.remove(d.mesh);
drones.splice(i, 1);
}
}
// Снаряды
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.mesh.position.addScaledVector(p.velocity, dt);
p.life += dt;
// Вращение для красоты
p.mesh.rotation.x += dt * 15;
if (p.life > p.maxLife) {
scene.remove(p.mesh);
projectiles.splice(i, 1);
}
}
checkProjectileHits();
updateExplosions(dt);
updateFires(dt, timeAcc);
// Проверка завершения уровня
if (state.phase === "play"
&& state.spawned >= state.totalToSpawn
&& drones.length === 0
&& state.hits < MAX_HITS_FOR_LOSE) {
// Уровень пройден
state.phase = "win";
state.nextLevelDelay = 0;
showOverlay(
`Уровень ${state.level} пройден!`,
`Сбито дронов: ${state.killed} из ${state.totalToSpawn}. Жизни: ${state.lives}/10`,
`Уровень ${state.level + 1}`
);
}
renderer.render(scene, camera);
}
// ---------- Оверлей / состояние ----------
function showOverlay(title, text, btnText) {
ovTitle.textContent = title;
ovText.textContent = text;
ovBtn.textContent = btnText;
overlay.classList.remove("hidden");
}
function hideOverlay() {
overlay.classList.add("hidden");
}
ovBtn.addEventListener("click", () => {
initAudio();
if (state.phase === "menu") {
resetGame();
startGameplay();
} else if (state.phase === "win") {
startLevel(state.level + 1);
state.phase = "play";
hideOverlay();
} else if (state.phase === "lose") {
resetGame();
startGameplay();
}
});
function resetGame() {
// Очистить всё
for (const d of drones) scene.remove(d.mesh);
drones.length = 0;
for (const p of projectiles) scene.remove(p.mesh);
projectiles.length = 0;
for (const f of fires) scene.remove(f.group);
fires.length = 0;
for (const e of explosions) scene.remove(e.group);
explosions.length = 0;
state.phase = "menu";
state.level = 1;
state.lives = MAX_LIVES;
state.hits = 0;
state.killed = 0;
state.spawned = 0;
state.totalToSpawn = 0;
state.fireTimer = 0;
state.spawnTimer = 0;
shakeAmount = 0;
updateHUD();
}
function startGameplay() {
state.phase = "play";
startLevel(1);
hideOverlay();
}
function endGame(win) {
state.phase = "lose";
showOverlay(
"Склад уничтожен!",
`Уровень ${state.level}. Сбито дронов: ${state.killed}. Попаданий: ${state.hits}/10.`,
"Играть заново"
);
}
// ---------- Старт ----------
function init() {
camera.position.copy(cameraBase);
camera.lookAt(cameraTarget);
updateHUD();
showOverlay(
"ПВО — Защита склада",
"Управляй зенитной установкой «ПВО» на крыше склада Яндекса. Сбивай чёрные дроны самолётного типа. Не дай им попасть в склад! 10 попаданий — и склада не станет.",
"Начать игру"
);
animate();
}
init();
})();