/** * GdLevelManager — авто-обработка всех GD-объектов в Play-режиме. * * Находит примитивы по их kind ('gd_portal', 'gd_spike', 'gd_finish', 'gd_coin') * и при пересечении с игроком автоматически выполняет нужное действие: * - gd_portal → переключение гейммода (Cube/Ship/Ball/UFO/Wave/Robot) * - gd_spike → смерть и respawn (telepor на spawnPoint) * - gd_finish → событие 'gdFinish' (скрипт показывает win-screen) * - gd_coin → +1 монета (скрытие меша, событие 'gdCoinCollected') * * Цель: дать пользователю возможность ставить GD-объекты в редакторе из палитры, * и всё работает автоматически без скрипта-обвязки. * * События пробрасываются в скрипт через `gameRuntime.routeGlobalEvent`, * чтобы скрипт мог показывать UI и проигрывать SFX. */ import { getPrimitiveType } from './PrimitiveTypes'; const PORTAL_RADIUS = 1.0; const SPIKE_RADIUS = 0.7; const FINISH_RADIUS = 1.5; const COIN_RADIUS = 1.4; const PORTAL_COOLDOWN = 1.0; const DEATH_COOLDOWN = 0.5; // после смерти не убиваем 0.5с (даём respawn-окно) export class GdLevelManager { constructor(scene3d) { this.scene3d = scene3d; this._observer = null; this._portals = []; // [{ x, y, z, gdMode, primId }] this._spikes = []; // [{ x, y, z, primId }] this._finishes = []; // [{ x, y, z, primId }] this._coins = []; // [{ x, y, z, primId, collected }] this._portalCooldown = 0; this._deathCooldown = 0; this._winSent = false; this._onPortalEnter = null; this._onDeath = null; this._onFinish = null; this._onCoinCollected = null; this._lastPmCount = -1; // для авто-refresh когда primitiveManager наполнится } setOnPortalEnter(cb) { this._onPortalEnter = cb; } setOnDeath(cb) { this._onDeath = cb; } setOnFinish(cb) { this._onFinish = cb; } setOnCoinCollected(cb) { this._onCoinCollected = cb; } start() { this._refreshObjectList(); const scene = this.scene3d?.scene; if (!scene) return; this._winSent = false; this._deathCooldown = 0; this._portalCooldown = 0; this._observer = scene.onBeforeRenderObservable.add(() => this._tick()); } stop() { const scene = this.scene3d?.scene; if (scene && this._observer) { scene.onBeforeRenderObservable.remove(this._observer); } this._observer = null; const p = this.scene3d?.player; if (p) { p._shipMode = false; p._ufoMode = false; p._waveMode = false; p._robotMode = false; p._gravityDir = 1; p._robotBoostLeft = 0; } } /** Собрать все GD-объекты из текущего набора примитивов. */ _refreshObjectList() { this._portals = []; this._spikes = []; this._finishes = []; this._coins = []; const pm = this.scene3d?.primitiveManager; if (!pm) return; for (const data of pm.instances.values()) { const typeDef = getPrimitiveType(data.type); if (!typeDef) continue; const base = { x: data.x, y: data.y, z: data.z, primId: data.id }; if (typeDef.kind === 'gd_portal') { this._portals.push({ ...base, gdMode: typeDef.gdMode }); } else if (typeDef.kind === 'gd_spike') { this._spikes.push(base); } else if (typeDef.kind === 'gd_finish') { this._finishes.push(base); } else if (typeDef.kind === 'gd_coin') { this._coins.push({ ...base, collected: false }); } } } /** Очистка состояния (вызывается при respawn). Монеты сохраняются — собранные собраны. */ resetForRespawn() { this._deathCooldown = DEATH_COOLDOWN; this._portalCooldown = 0; // Режим возвращаем к Cube const p = this.scene3d?.player; if (p) { p._shipMode = false; p._ufoMode = false; p._waveMode = false; p._robotMode = false; p._gravityDir = 1; p._robotBoostLeft = 0; p._ballHintActive = false; } } /** Сбросить ВСЁ (включая монеты) — для рестарта уровня. */ resetForNewRun() { this.resetForRespawn(); this._winSent = false; for (const coin of this._coins) { coin.collected = false; try { const data = this.scene3d?.primitiveManager?.instances?.get(coin.primId); if (data?.mesh) data.mesh.setEnabled(true); } catch (e) {} } } _currentMode() { const p = this.scene3d?.player; if (!p) return 'cube'; if (p._shipMode) return 'ship'; if (p._ufoMode) return 'ufo'; if (p._waveMode) return 'wave'; if (p._robotMode) return 'robot'; if ((p._gravityDir || 1) < 0 || p._ballHintActive) return 'ball'; return 'cube'; } _setMode(gdMode) { const p = this.scene3d?.player; if (!p) return; console.log('[GdLevelManager] _setMode →', gdMode); p._shipMode = false; p._ufoMode = false; p._waveMode = false; p._robotMode = false; p._ballHintActive = (gdMode === 'ball'); if (gdMode === 'ship') p._shipMode = true; if (gdMode === 'ufo') p._ufoMode = true; if (gdMode === 'wave') p._waveMode = true; if (gdMode === 'robot') p._robotMode = true; if (gdMode === 'cube') { p._gravityDir = 1; p._robotBoostLeft = 0; } } _tick() { const p = this.scene3d?.player; if (!p) return; const dt = (this.scene3d.scene.getEngine().getDeltaTime() / 1000) || 0.016; this._portalCooldown = Math.max(0, this._portalCooldown - dt); this._deathCooldown = Math.max(0, this._deathCooldown - dt); // Автообновление: primitives загружаются асинхронно после start(). // Считаем кол-во в primitiveManager и при изменении — refresh всех списков. const pmCount = this.scene3d?.primitiveManager?.instances?.size || 0; if (pmCount !== this._lastPmCount) { this._lastPmCount = pmCount; this._refreshObjectList(); console.log(`[GdLevelManager] refresh: pm=${pmCount}, portals=${this._portals.length}, spikes=${this._spikes.length}, finishes=${this._finishes.length}, coins=${this._coins.length}`); } // PlayerController хранит позицию в _pos (не в .position — там нет геттера). const ppos = p._pos || p.position; const px = ppos?.x ?? 0; const py = ppos?.y ?? 0; // === Порталы (приоритет: они перекрывают всё) === // Триггер — пересечение по X на любой высоте 0..12 (как у финиша). // Чтобы в ship-режиме игрок не пролетел над cube-порталом сверху. if (this._portalCooldown <= 0) { for (const portal of this._portals) { const dx = px - portal.x; if (Math.abs(dx) < PORTAL_RADIUS * 1.5 && py >= 0 && py <= 12) { const cur = this._currentMode(); if (cur === portal.gdMode) continue; const prevMode = cur; this._setMode(portal.gdMode); this._portalCooldown = PORTAL_COOLDOWN; if (this._onPortalEnter) { try { this._onPortalEnter(portal.gdMode, prevMode); } catch (e) {} } break; } } } // === Финиш (приоритет 2: победа важнее смерти) === // Финиш = вертикальный столб (cylinder neon sy=8). Триггер — пересечение по X // на любой допустимой высоте (от пола y=0 до y=8). Это покрывает все режимы // (cube/ship/ball/ufo/wave/robot — игрок может быть на разной высоте). if (!this._winSent) { for (const fin of this._finishes) { const dx = px - fin.x; // По X — узко (1.5м), по Y — широко (от 0 до 10) if (Math.abs(dx) < FINISH_RADIUS && py >= -1 && py <= 10) { this._winSent = true; if (this._onFinish) { try { this._onFinish({ x: fin.x, y: fin.y }); } catch (e) {} } return; } } } // === Шипы (смерть) === if (this._deathCooldown <= 0) { for (const spike of this._spikes) { const dx = px - spike.x; const dy = py - spike.y; if (dx*dx + dy*dy < SPIKE_RADIUS * SPIKE_RADIUS) { this.resetForRespawn(); if (this._onDeath) { try { this._onDeath({ x: spike.x, y: spike.y, cause: 'spike' }); } catch (e) {} } return; } } // Также «упал в бездну» — y < -2 if (py < -2) { this.resetForRespawn(); if (this._onDeath) { try { this._onDeath({ x: px, y: py, cause: 'fall' }); } catch (e) {} } return; } } // === Монеты (сбор) === for (const coin of this._coins) { if (coin.collected) continue; const dx = px - coin.x; const dy = py - coin.y; if (dx*dx + dy*dy < COIN_RADIUS * COIN_RADIUS) { coin.collected = true; try { const data = this.scene3d?.primitiveManager?.instances?.get(coin.primId); if (data?.mesh) data.mesh.setEnabled(false); } catch (e) {} if (this._onCoinCollected) { try { this._onCoinCollected({ primId: coin.primId, index: this._coins.indexOf(coin) }); } catch (e) {} } } } } /** Сколько собрано монет / всего. Для HUD. */ getCoinsStats() { const total = this._coins.length; const collected = this._coins.filter(c => c.collected).length; return { collected, total }; } }