/
CodeByKate
/
lastserver-game
Обзор
Документация
Войти
/
CodeByKate
/
lastserver-game
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
levels.js
1 129 строк
51 KB
CodeByKate
upload files
21 дек 2025, 21:44
21 дек 2025, 21:44
136e74f
Код
Авторство
О чём код?
/* levels.js LevelManager with per-level descriptors, backgrounds, NPCs and custom platform layouts. - Level 1: refined ladder tutorial (slow decay, short overlay) - Level 2: "Глитч-город" atmospheric skyline, now redesigned as a 2000s-style glitch megacity - Levels 3-5: placeholders Green goal platform is placed high-right and kept separate from grey platforms. NOTE: Added a small entrance marker and simple DOM-based teleports (2-3) for level 3. */ export default class LevelManager { constructor(engine, entities, ui){ this.engine = engine; this.entities = entities; this.ui = ui; this.current = 1; this.total = 5; this._watcher = null; this._tutorialShown = false; // DOM helpers for teleports/entrance (created only for level 3) this._teleportElems = []; this._entranceElem = null; this._tpLoop = null; // per-level descriptors with generator functions this.levels = [ { id:1, title: 'Уровень 1: Запуск системы', setup: (mgr)=> mgr._setupLevel1() }, { id:2, title: 'Уровень 2: Глитч-город', setup: (mgr)=> mgr._setupLevel2() }, { id:3, title: 'Уровень 3: Баг-лабиринт', setup: (mgr)=> mgr._setupLevel3() }, { id:4, title: 'Уровень 4: Кодовая пустошь', setup: (mgr)=> mgr._setupLevel4() }, { id:5, title: 'Уровень 5: Ядро системы', setup: (mgr)=> mgr._setupLevel5() } ]; } loadLevel(n){ const idx = Math.max(1, Math.min(this.total, Math.floor(n || 1))); this.current = idx; // reset engine state this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayPaused = false; this.engine.gameOver = false; this.engine.win = false; this.engine._winHandled = false; this.engine.paused = false; // teardown any previous level-3 UI/loops this._teardownLevel3Extras(); // Rebuild platforms and entities according to level const meta = this.levels[this.current-1]; if(this.entities){ // call specific setup if(meta && typeof meta.setup === 'function'){ meta.setup(this); } else { this.entities.createTestLevel(); } // ensure green goal placement is consistent (top-right) and remove any overlapping platform this._placeGoal(); // reset player positions and platform states if(typeof this.entities.reset === 'function') this.entities.reset(); } // adjust decayTimerMax per level (longer for tutorial, shorter for city) if(this.current === 1){ this.engine.decayTimerMax = 300; // 5 min tutorial this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayRate = 1.0; // show a short overlay tutorial for first load if(this.ui && !this._tutorialShown){ this._tutorialShown = true; this._showTutorialOverlay("Доведи Леру и Макса до зелёной платформы вместе!", 10); } } else if(this.current === 2){ // Level 2: make stability shorter so windows vanish faster — 3 minutes this.engine.decayTimerMax = 180; // 3 min urgent city this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayRate = 1.4; } else { this.engine.decayTimerMax = 300; this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayRate = 1.0; } // update UI title if possible if(this.ui && this.ui.dom && this.ui.dom.levelTitle){ this.ui.dom.levelTitle.textContent = meta.title; } else { const el = document.getElementById('level-title'); if(el) el.textContent = meta.title; } // clear existing watcher and watch for win if(this._watcher) { clearInterval(this._watcher); this._watcher = null; } this._watcher = setInterval(()=> this._checkWin(), 250); // If level 3, initialize entrance marker and teleports DOM and logic if(this.current === 3){ this._setupLevel3Extras(); } } _showTutorialOverlay(text, seconds){ const root = document.getElementById('game-root'); if(!root) return; const ov = document.createElement('div'); ov.style.position = 'absolute'; ov.style.left = '50%'; ov.style.top = '18px'; ov.style.transform = 'translateX(-50%)'; ov.style.padding = '8px 12px'; ov.style.background = 'rgba(0,0,0,0.6)'; ov.style.color = '#cfeefb'; ov.style.fontSize = '12px'; ov.style.borderRadius = '6px'; ov.style.zIndex = 1500; ov.style.pointerEvents = 'none'; ov.textContent = text; root.appendChild(ov); setTimeout(()=> { if(ov && ov.parentElement) ov.parentElement.removeChild(ov); }, seconds*1000); } _placeGoal(){ // Ensure a single green goal platform at top-right and remove any overlapping grey const entities = this.entities; if(!entities || !entities.platforms) return; // remove existing goal(s) entities.platforms = entities.platforms.filter(p => !p.isGoal); // add goal at constrained coords (right-top area). Use a range so layouts can aim for it. const goalX = 700; // near right edge const goalY = 90; // top area (80-100) const goalW = 150; const goalH = 20; const goal = {x:goalX, y:goalY, w:goalW, h:goalH, alpha:1, decaying:false, exists:true, isGoal:true, startDelay:0, decayDuration:0, _age:0, _started:false}; // remove any grey that intersects goal area (keep ground) entities.platforms = entities.platforms.filter(p=>{ if(p.y >= entities.height - 40) return true; const overlap = !(p.x + p.w <= goal.x || p.x >= goal.x + goal.w || p.y + p.h <= goal.y || p.y >= goal.y + goal.h); return !overlap; }); entities.platforms.push(goal); } _setupLevel1(){ // Ladder-style tutorial: 8 steps leading to a top step that connects to the green goal const rand = (a,b)=> a + Math.random()*(b-a); const ground = {x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false}; // gentle upward-right staircase; last platform sits just left of the green goal so player can step onto it const p1 = {x:40, y:500, w:140, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(40,60), decayDuration:rand(220,260), _age:0, _started:false}; const p2 = {x:150, y:440, w:120, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(34,54), decayDuration:rand(200,240), _age:0, _started:false}; const p3 = {x:260, y:380, w:110, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(28,48), decayDuration:rand(180,220), _age:0, _started:false}; const p4 = {x:370, y:320, w:110, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(22,40), decayDuration:rand(160,200), _age:0, _started:false}; const p5 = {x:480, y:260, w:120, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(16,32), decayDuration:rand(140,180), _age:0, _started:false}; const p6 = {x:560, y:200, w:100, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(12,24), decayDuration:rand(120,160), _age:0, _started:false}; const p7 = {x:620, y:140, w:80, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(8,18), decayDuration:rand(100,140), _age:0, _started:false}; // final grey step placed to lead directly to the isolated green goal (goal will be placed at x ~700,y=90) const p8 = {x:680, y:110, w:40, h:14, alpha:1, decaying:true, exists:true, startDelay:rand(6,14), decayDuration:rand(80,120), _age:0, _started:false}; this.entities.platforms = [ground, p1,p2,p3,p4,p5,p6,p7,p8]; // clear any level background flags to ensure neutral gradient this.entities._levelBackground = { type:'none' }; // no NPCs for tutorial this.entities._npcs = []; } _setupLevel2(){ // Reworked "Глитч-город": explicit parkour path with a fire-escape ladder and tightly placed platforms/NPCs. const rand = (a,b)=> a + Math.random()*(b-a); const ground = {x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false}; // Helper to create a decaying platform const plat = (x,y,w,delay,dur) => ({ x, y, w, h:14, alpha:1, decaying:true, exists:true, startDelay:delay, decayDuration:dur, _age:0, _started:false }); // Building base X positions (visual facades drawn in render) const b1x = 80; // left facade approx near x=80..200 const b2x = 320; // center facade approx near x=320..480 const b3x = 560; // right facade approx near x=560..760 // Fire-escape ladder on building 1: vertical chain of 5 platforms from low to high // Ladder platforms slightly right of ladder (x=100 requested), set x near 100 const ladderX = 100; const ladderPlatforms = [ plat(ladderX, 560, 80, rand(6,12), rand(40,55)), // near ground plat(ladderX, 480, 72, rand(6,12), rand(36,50)), plat(ladderX, 400, 72, rand(5,10), rand(32,46)), plat(ladderX, 340, 72, rand(4,9), rand(28,40)), plat(ladderX, 280, 72, rand(3,8), rand(24,36)) ]; // Center building path: platforms across y=500,400,300,200 between x ~350-450 const centerPlatforms = [ plat(350, 500, 120, rand(6,12), rand(28,44)), // landing after ladder plat(380, 400, 110, rand(5,10), rand(26,40)), plat(360, 300, 100, rand(4,9), rand(22,36)), plat(400, 200, 100, rand(3,8), rand(18,30)) ]; // Right building path: platforms at y=450,350,250,150 and rooftop near y=100 with goal area const rightPlatforms = [ plat(620, 450, 110, rand(5,10), rand(26,40)), // first arrival from center plat(600, 350, 100, rand(4,9), rand(22,36)), plat(640, 250, 90, rand(3,8), rand(18,32)), plat(620, 150, 120, rand(2,6), rand(14,28)), // rooftop ledge (kept decaying so timing matters); green goal later placed by LevelManager._placeGoal near top-right plat(600, 100, 160, rand(1,4), rand(12,24)) ]; // Compose final platforms list: ground + logical path only this.entities.platforms = [ ground, ...ladderPlatforms, ...centerPlatforms, ...rightPlatforms ]; // NPCs: placed on actual platform coordinates (y adjusted so character stands on platform) this.entities._npcs = [ // NPC1 on ladder around y=400 { id:'npc_ladder', x: ladderX + 36, y: 400 - 6, text: "Поднимайся по лестнице, пока она не исчезла!", shown:false }, // NPC2 on center building at y=400 { id:'npc_mid', x: 420, y: 400 - 6, text: "Город глючит… всё рушится вокруг.", shown:false }, // NPC3 on right building at y=250 { id:'npc_high', x: 660, y: 250 - 6, text: "Кронос стирает всё… быстрее к ядру!", shown:false }, // NPC4 on rooftop near y=100 { id:'npc_roof', x: 700, y: 100 - 6, text: "Вы почти у цели. Не сдавайтесь!", shown:false } ]; // Level background: simplified building colors and stable window lighting (no blinking) this.entities._levelBackground = { type: 'glitchCity', buildings: [ { x: b1x - 40, width: 220, floors: 4, color: '#6b3f2a', litRatio: 0.26 }, // left: slightly lighter brown { x: b2x - 40, width: 260, floors: 6, color: '#163248', litRatio: 0.28 }, // center: deep blue { x: b3x - 40, width: 300, floors: 8, color: '#0f1830', litRatio: 0.30 } // right: dark navy ], scanlines: true, artefacts: true }; // Make decay a bit more urgent for level tension if(this.engine){ this.engine.decayRate = Math.max(1.0, (this.engine.decayRate || 1.0) * 1.35); } } _setupLevel3(){ // Pac‑Man style labyrinth using a 2D tile map (1 = wall, 0 = path). const tileSize = 28; const cols = 21; const rows = 19; // Symmetric classic-like tile map (21x19) - 1 = wall, 0 = corridor. const MAP = [ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], [1,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,1], [1,0,1,1,1,0,0,1,0,1,1,1,0,1,0,0,1,1,1,0,1], [1,0,1,0,1,0,0,1,0,1,0,1,0,1,0,0,1,0,1,0,1], [1,0,1,0,1,1,1,1,0,1,0,1,1,1,0,0,1,0,1,0,1], [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], [1,0,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,0,1], [1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1], [1,1,1,0,1,1,1,0,1,1,1,0,1,1,1,1,0,1,1,1,1], [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], [1,0,1,1,1,0,1,1,1,1,1,1,1,1,0,1,1,1,1,0,1], [1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,1], [1,0,1,0,1,1,1,1,0,1,0,1,1,1,0,0,1,0,1,0,1], [1,0,1,0,1,0,0,1,0,1,0,1,0,1,0,0,1,0,1,0,1], [1,0,1,1,1,0,0,1,0,1,1,1,0,1,0,0,1,1,1,0,1], [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], [1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1], [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1], [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] ]; // Build wall rectangles from MAP tiles and set up platforms array (walls behave like decaying maze walls) const walls = []; const startX = Math.round((this.entities.width - cols * tileSize) / 2); // center maze horizontally const startY = 40; // top padding for(let r=0;r<rows;r++){ for(let c=0;c<cols;c++){ if(MAP[r][c] === 1){ const x = startX + c * tileSize; const y = startY + r * tileSize; walls.push({ x, y, w: tileSize, h: tileSize, alpha: 1, decaying: true, exists: true, startDelay: 0, decayDuration: 120, _age:0, _started:true, _isMazeTile:true }); } } } // Include ground so physics still has floor (but characters navigate corridors) const ground = {x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false}; // platforms for collision = ground + wall tiles (walls are non-passable) this.entities.platforms = [ ground, ...walls ]; // Teleport points (5) placed in dead-end-like corridor locations (map coordinates converted) const teleportTiles = [ {c:1,r:1},{c:19,r:1},{c:1,r:15},{c:19,r:15},{c:10,r:9} ]; this.entities._teleports = teleportTiles.map((t,idx)=>({ id: 'tp'+(idx+1), x: startX + t.c*tileSize + tileSize/2, y: startY + t.r*tileSize + tileSize/2, r: 14, // slightly larger for touch pulse: 0.0, // runtime pulse for animation targetIds: null // will be assigned by engine logic (or used randomly) })); // Gravity bug pads (4) inside corridors (flip or heavy) this.entities._gravityPads = [ { id:'g1', x: startX + 3*tileSize, y: startY + 16*tileSize, w: tileSize*3, h: tileSize*2, type:'invert', duration:4.0, active:false }, { id:'g2', x: startX + 8*tileSize, y: startY + 16*tileSize, w: tileSize*3, h: tileSize*2, type:'heavy', duration:4.0, active:false }, { id:'g3', x: startX + 13*tileSize, y: startY + 16*tileSize, w: tileSize*3, h: tileSize*2, type:'invert', duration:4.0, active:false }, { id:'g4', x: startX + 9*tileSize, y: startY + 6*tileSize, w: tileSize*4, h: tileSize*2, type:'heavy', duration:4.0, active:false } ]; // Select a sample of glitchable wall segments which can temporarily disappear/warp const glitchIndices = []; for(let i=0;i<walls.length && glitchIndices.length<8;i+=Math.max(1,Math.floor(walls.length/8))){ glitchIndices.push(i); } this.entities._glitchWalls = glitchIndices.map(i=> ({ x: walls[i].x, y: walls[i].y, w: walls[i].w, h: walls[i].h, active:false, timer:0 })); // Maze metadata this.entities._mazeTiles = walls; this.entities._levelMeta = { tileSize, cols, rows, startX, startY, teleports: this.entities._teleports, gravityPads: this.entities._gravityPads, glitchWalls: this.entities._glitchWalls, wallFadeDuration: 120 // walls fade over 2 minutes by default }; // NPCs: central puzzle NPC in the center plaza and 3 helpers; shift NPCs off walls to be on corridors/open tiles const centerCol = Math.floor(cols/2), centerRow = Math.floor(rows/2); const centerX = startX + centerCol * tileSize + tileSize/2; const centerY = startY + centerRow * tileSize + tileSize/2; const shift = 20; // move NPCs into open corridor away from walls this.entities._npcs = [ { id: 'npc_puzzle', x: centerX, y: centerY, text: "Весит груша, нельзя скушать.", puzzle: true, puzzlePrompt: "Выберите: 1. Лампочка 2. Звезда 3. Луна", choices: [ { id:1, text:'Лампочка' }, { id:2, text:'Звезда' }, { id:3, text:'Луна' } ], correctChoice: 1, shown:false, ghostLike:true }, // 3 small hint NPCs placed on corridors (shifted) { id:'npc_g1', x: startX + 3*tileSize + shift, y: startY + 3*tileSize + shift/2, text: "Баги в стенах!", shown:false }, { id:'npc_g2', x: startX + 17*tileSize - shift, y: startY + 3*tileSize + shift/2, text: "Смотри под ногами.", shown:false }, { id:'npc_g3', x: startX + 10*tileSize, y: startY + 14*tileSize - shift, text: "Ищи порталы.", shown:false } ]; // Level background and entrance/exit arches: // Entrance: left-bottom arch (80x100), Exit: right-top arch (100x120) const entrance = { x: startX + 1 * tileSize - 8, y: startY + (rows - 2) * tileSize - 6, w:80, h:100, id:'entrance' }; const exit = { x: startX + (cols-2) * tileSize - 16, y: startY + 1 * tileSize - 54, w:100, h:120, id:'exit' }; this.entities._levelBackground = { type: 'pacmaze', color: '#000000', wallColor: '#9a9a9a', // grey walls (preserve user's grey-scheme) wallThickness: 20, entrance, exit }; // Tighter decay: walls will gradually fade over ~2 minutes (120s) and level stability shortened if(this.engine){ this.engine.decayTimerMax = 150; this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayRate = 1.6; } // Prepare per-wall runtime fade trackers this.entities._wallFadeStates = this.entities._mazeTiles.map((t, idx) => ({ id: idx, alpha:1.0, glitching:false, glitchTimer:0 })); // Place players inside the entrance arch and increase jump a bit for necessary reach on this level. if(this.entities.players){ const entryX = entrance.x + Math.round((entrance.w - this.entities.players.lera.w)/2); const entryY = entrance.y + Math.round((entrance.h - this.entities.players.lera.h)/2); this.entities.players.lera.x = entryX; this.entities.players.lera.y = entryY; this.entities.players.max.x = entryX + 28; this.entities.players.max.y = entryY; // Slightly increase jump if needed (kept modest) this.entities.players.lera.jumpPower = Math.max(400, this.entities.players.lera.jumpPower || 420); this.entities.players.max.jumpPower = Math.max(400, this.entities.players.max.jumpPower || 420); } // Mark teleports to reference each other for runtime logic (engine/entities update can use this list) const ids = this.entities._teleports.map(tp => tp.id); this.entities._teleports.forEach((tp, i) => { // destinations exclude itself; used by runtime teleport logic to pick targets tp.targetIds = ids.filter(id => id !== tp.id); }); // Add small runtime flags so rendering/interaction systems can animate teleports and gravity pads this.entities._teleports.forEach(tp => { tp.pulse = 0; tp.active = true; }); this.entities._gravityPads.forEach(gp => { gp.active = true; gp._timer = 0; }); // Reset entities to apply new level state if(typeof this.entities.reset === 'function') this.entities.reset(); } _setupPlaceholder(color){ // Generic placeholder platforms and a background color flag const ground = {x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false}; const p1 = {x:60, y:460, w:160, h:14, alpha:1, decaying:true, exists:true, startDelay:20, decayDuration:120, _age:0, _started:false}; const p2 = {x:260, y:380, w:120, h:14, alpha:1, decaying:true, exists:true, startDelay:18, decayDuration:100, _age:0, _started:false}; const p3 = {x:420, y:300, w:120, h:14, alpha:1, decaying:true, exists:true, startDelay:14, decayDuration:90, _age:0, _started:false}; this.entities.platforms = [ground,p1,p2,p3]; this.entities._levelBackground = { type:'color', color: color || '#222' }; this.entities._npcs = []; } // --- Level 5: Core of the System --- _setupLevel5(){ // Rewritten final: "Ядро системы" — blue glowing core, Kronos antagonist rectangle, one cooperative marker, decision4 influence and endings. const rand = (a,b)=> a + Math.random()*(b-a); // Ground (safety) const ground = { x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false }; // Core center and params (as requested: x=400,y=300,r=150) const cx = 400, cy = 300, coreRadius = 150; // Create 10 ring platforms around core (fast decay) const ringCount = 10; const ringPlatforms = []; const ringIndices = []; for(let i=0;i<ringCount;i++){ const ang = (i / ringCount) * Math.PI * 2; const px = Math.round(cx + Math.cos(ang) * (coreRadius + 80)) - 60/2; const py = Math.round(cy + Math.sin(ang) * (coreRadius + 60)) - 14/2; const p = { x: px, y: py, w: 80, h: 14, alpha: 1, decaying:true, exists:true, startDelay: 0.6 + i*0.1, decayDuration: 60 + Math.random()*20, // relatively quick _age:0, _started:false, _jitter:0 }; ringIndices.push(ringPlatforms.length + 1); // +1 because ground will be at index 0 later ringPlatforms.push(p); } // A few inner shards (visual/decay) const shards = []; for(let i=0;i<6;i++){ const ang = (i / 6) * Math.PI * 2 + 0.2; const px = Math.round(cx + Math.cos(ang) * (coreRadius + 36)) - 36/2; const py = Math.round(cy + Math.sin(ang) * (coreRadius + 36)) - 12/2; shards.push({ x:px, y:py, w:48, h:12, alpha:1, decaying:true, exists:true, startDelay:0.8 + i*0.12, decayDuration:56 + i*6, _age:0, _started:false }); } // Inner green finish area placed literally inside core const goal = { x: cx - 60/2, y: cy - 36/2, w: 60, h: 36, alpha:1, decaying:false, exists:true, isGoal:true, startDelay:0, decayDuration:0, _age:0, _started:false }; // Assign to entities.platforms (ground + ring + shards + goal) this.entities.platforms = [ ground, ...ringPlatforms, ...shards, goal ]; // Expose ringIndices for Kronos attacks this.entities._finalRing = { ringIndices }; // Reactor background metadata (dark-red pulsing per request but core visual is blue circle) this.entities._levelBackground = { type: 'reactor', color:'#23060a', coreCenter:{x:cx,y:cy,r:coreRadius}, pulseSpeed: 1.2 }; // Kronos: red blinking rectangle that appears after 10s and moves chaotically inside core this.entities._kronos = { x: cx, y: cy, w: 80, h: 120, cx, cy, r: coreRadius - 18, present:false, spawnTimer:10.0, alive:true, moveTimer:0, moveInterval:2.0, target:{x:cx,y:cy}, blinkTimer:0, blinkInterval:0.25, blinkOn:true, dialogueTimer:0, dialogueInterval:20.0, frozenTimer:0, attackTimer:0, attackIntervalBase:2.0 }; // Single cooperative marker (green) placed near core (players must repair it together) const marker = { id:'core_task', x: cx + Math.round(coreRadius * 0.6), y: cy - 6, r:22, progress:0, completed:false, difficulty:1.0, active:false }; // Decision4 influence const d4 = (this.engine && typeof this.engine.decision4 !== 'undefined') ? this.engine.decision4 : null; if(this.engine){ this.engine.decayTimerMax = 120; // 2 minutes baseline this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayRate = 2.0; } if(d4 === 1){ // Kronos stronger: faster destruction penalty if(this.engine) this.engine.decayTimer = Math.max(0, this.engine.decayTimer - Math.round(this.engine.decayTimerMax * 0.20)); marker.difficulty = 1.6; this.entities._kronos.attackIntervalBase *= 0.75; this.entities._kronos.moveInterval = 1.0; } else if(d4 === 2){ // Kronos weaker if(this.engine) this.engine.decayTimer = Math.min(this.engine.decayTimerMax, this.engine.decayTimer + Math.round(this.engine.decayTimerMax * 0.15)); marker.difficulty = 0.9; this.entities._kronos.attackIntervalBase *= 1.2; this.entities._kronos.moveInterval = 3.0; } else if(d4 === 3){ // harder task but best ending marker.difficulty = 2.0; this.entities._kronos.attackIntervalBase *= 1.0; marker.holdScale = 1.4; // mini-game increased difficulty } this.entities._finalMarkers = [ marker ]; // Remove extra NPCs—only Kronos remains as antagonist; keep npc array minimal for compatibility this.entities._npcs = []; // Position players at left/right of ring if(this.entities.players){ this.entities.players.lera.x = cx - 180; this.entities.players.lera.y = cy + 40; this.entities.players.max.x = cx + 160; this.entities.players.max.y = cy + 40; // slight jump tweak per earlier requests this.entities.players.lera.jumpPower = Math.max(520, this.entities.players.lera.jumpPower || 520); this.entities.players.max.jumpPower = Math.max(520, this.entities.players.max.jumpPower || 520); } // Reset entities so changes apply if(typeof this.entities.reset === 'function') this.entities.reset(); // Create simple DOM overlay for core visuals & marker interaction (used for mini-game) const root = document.getElementById('game-root'); if(root){ // core canvas overlay (for heartbeat visual) - use entity metadata only; rendering will be handled in Entities.render // create marker hint element (small) if(!document.getElementById('core-marker-hint')){ const el = document.createElement('div'); el.id = 'core-marker-hint'; Object.assign(el.style, { position:'absolute', left:`${cx - 12}px`, top:`${cy - coreRadius - 44}px`, width:'24px', height:'24px', borderRadius:'50%', background:'#21c35b', boxShadow:'0 0 12px rgba(33,195,91,0.6)', pointerEvents:'none', zIndex:1400 }); root.appendChild(el); } } // Add drawing/interaction hooks: augment Entities.render to draw the core, marker, Kronos etc via metadata // We'll also run a compact final-loop to manage Kronos & marker interactions if(this._finalLoopFrame) cancelAnimationFrame(this._finalLoopFrame); let last = performance.now(); const loop = () => { const now = performance.now(); const dt = Math.min(1000/60, now - last) / 1000; last = now; const kron = this.entities._kronos; const m = this.entities._finalMarkers[0]; const cx = this.entities._levelBackground.coreCenter.x; const cy = this.entities._levelBackground.coreCenter.y; // Spawn kronos after timer if(kron && kron.alive && !kron.present){ kron.spawnTimer = Math.max(0, kron.spawnTimer - dt); if(kron.spawnTimer <= 0){ kron.present = true; if(this.engine) this.engine._triggerFlash('red', 700); } } if(kron && kron.present && kron.alive){ // blink kron.blinkTimer += dt; if(kron.blinkTimer >= kron.blinkInterval){ kron.blinkTimer = 0; kron.blinkOn = !kron.blinkOn; } // glitchy shake every 0.5s: implemented as small randomized offset applied in render; here update move/teleport kron.moveTimer += dt; const moveInt = (d4 === 1) ? 1.0 : (d4 === 2 ? 3.0 : kron.moveInterval || 2.0); if(kron.moveTimer >= moveInt){ kron.moveTimer = 0; // teleport to new random position inside core circle const ang = Math.random()*Math.PI*2; const r = Math.random() * (kron.r * 0.6); kron.x = kron.cx + Math.cos(ang) * r; kron.y = kron.cy + Math.sin(ang) * r; // small immediate blink flash if(this.engine) this.engine._triggerFlash('red', 120); } // dialogue timer: phrases occasionally kron.dialogueTimer += dt; if(kron.dialogueTimer >= kron.dialogueInterval){ kron.dialogueTimer = 0; const phrases = ["Сервер мой!", "Ваши баги ничто!", "Кронос победит!"]; const text = phrases[Math.floor(Math.random()*phrases.length)]; // bubble near top const root = document.getElementById('game-root'); if(root){ const b = document.createElement('div'); b.style.position = 'absolute'; b.style.left = `${cx - 120}px`; b.style.top = '18px'; b.style.padding = '8px 10px'; b.style.background = 'rgba(30,8,8,0.9)'; b.style.color = '#ffb3b3'; b.style.borderRadius = '6px'; b.style.pointerEvents = 'none'; b.style.zIndex = 2200; b.textContent = text; root.appendChild(b); setTimeout(()=>{ if(b && b.parentElement) b.parentElement.removeChild(b); }, 1400); } } // attack if touching players: check distance to both players const checkHit = (pl) => { const dx = (pl.x + pl.w/2) - kron.x; const dy = (pl.y + pl.h/2) - kron.y; return Math.hypot(dx,dy) < 36 + (pl.w/2); }; const l = this.entities.players.lera; const mm = this.entities.players.max; if(kron.frozenTimer <= 0){ if(l && checkHit(l)){ // knockback Lera l.vx = -200 * (Math.random() > 0.5 ? 1 : -1); l.vy = -260; if(this.engine) { this.engine.decayTimer = Math.max(0, this.engine.decayTimer - Math.round(this.engine.decayTimerMax * 0.10)); this.engine._triggerFlash('red', 160); } } if(mm && checkHit(mm)){ mm.vx = -220 * (Math.random() > 0.5 ? 1 : -1); mm.vy = -280; if(this.engine) { this.engine.decayTimer = Math.max(0, this.engine.decayTimer - Math.round(this.engine.decayTimerMax * 0.10)); this.engine._triggerFlash('red', 160); } } } } // Kronos frozen timer count down if(kron && kron.frozenTimer > 0){ kron.frozenTimer = Math.max(0, kron.frozenTimer - dt); if(kron.frozenTimer <= 0){ // unfreeze: resume normal decay if engine paused by freeze if(this.engine) this.engine.decayPaused = false; } else { // while frozen, pause global decay if(this.engine) this.engine.decayPaused = true; } } // Marker mini-game activation: detect Lera pressing E near marker (distance) const lera = this.entities.players.lera; if(lera && !m.completed){ const dist = Math.hypot((lera.x + lera.w/2) - m.x, (lera.y + lera.h/2) - m.y); if(dist < 48 && (this.engine.keys['e'] || this.engine.keys['E']) && !m.active){ // start mini-game: require holding E for duration (base 5s scaled) m.active = true; m.holdNeeded = 5 * (m.holdScale || (marker.holdScale || 1)); // if decision4==3 increase by 40% if(d4 === 3) m.holdNeeded = m.holdNeeded * 1.4; m.holdTimer = m.holdNeeded; // show a small HUD overlay to indicate progress const mg = document.createElement('div'); mg.id = 'core-mini'; Object.assign(mg.style, { position:'absolute', left:'50%', top:'50%', transform:'translate(-50%,-50%)', width:'320px', height:'110px', background:'rgba(0,0,0,0.85)', color:'#9ff0ff', padding:'10px', borderRadius:'8px', zIndex:2600, fontFamily:'monospace', textAlign:'center' }); mg.innerHTML = `<div style=\"font-size:14px;margin-bottom:8px\">Соедините разрыв — держите E</div><div id=\"core-mini-bar\" style=\"height:18px;background:rgba(255,255,255,0.06);border-radius:8px;overflow:hidden\"><div id=\"core-mini-fill\" style=\"height:100%;width:0%;background:linear-gradient(90deg,#39d353,#8ef08e)\"></div></div>`; document.getElementById('game-root').appendChild(mg); } if(m.active){ // if E is held, decrease timer, else slowly increase back (penalty) if(this.engine.keys['e'] || this.engine.keys['E']){ m.holdTimer = Math.max(0, m.holdTimer - dt); } else { m.holdTimer = Math.min(m.holdNeeded, m.holdTimer + dt * 0.45); } const pct = Math.round((1 - (m.holdTimer / m.holdNeeded)) * 100); const fill = document.getElementById('core-mini-fill'); if(fill) fill.style.width = `${pct}%`; if(m.holdTimer <= 0){ m.completed = true; m.active = false; // remove overlay const el = document.getElementById('core-mini'); if(el && el.parentElement) el.parentElement.removeChild(el); // marker turns blue (we'll reflect in render) if(this.engine) this.engine._triggerFlash('green', 420); // Kronos becomes vulnerable: freeze movement & blinking, set frozen state but not permanent if(kron){ kron.frozenTimer = Math.max(kron.frozenTimer, 9999); // huge value to mark vulnerability state (we'll treat >9000 as vulnerable) kron.present = true; } } } } // Max Q: trigger freeze wave when pressed and on platform outside core area and facing roughly towards core const maxp = this.entities.players.max; if(maxp && (this.engine.keys['q'] || this.engine.keys['Q']) && !this._qLockLocal){ this._qLockLocal = true; // simple line-of-sight check: if max is outside core radius and roughly facing center const mx = maxp.x + maxp.w/2, my = maxp.y + maxp.h/2; const toCenter = Math.atan2(cy - my, cx - mx); const facing = maxp.facing || 1; // require distance > coreRadius+20 (on outer ring) and allow any facing for simplicity const dcenter = Math.hypot(mx - cx, my - cy); if(dcenter > coreRadius - 10){ // apply freeze effect: if kron present and not already massively frozen, set frozenTimer to 10s if(kron && kron.present){ kron.frozenTimer = Math.max(kron.frozenTimer, 10.0); // pause decay if(this.engine) this.engine.decayPaused = true; // visual: trigger MaxBug visuals if exists if(this.entities.maxBug && !this.entities.maxBug.active){ // manually engage without starting cooldown visuals (MaxBug.tryUse enforces cooldown) - we call tryUse only if it's available this.entities.maxBug.tryUse(); } else { if(this.engine) this.engine.decayPaused = true; setTimeout(()=>{ if(this.engine) this.engine.decayPaused = false; }, 10000); } } } } else if(!this.engine.keys['q'] && !this.engine.keys['Q']){ this._qLockLocal = false; } // Victory check: marker completed AND both players inside core circle for 3 seconds const markerDone = m && m.completed; if(markerDone){ // require both players inside circle const pl = this.entities.players.lera; const pm = this.entities.players.max; const inCore = (p)=>{ const dx = (p.x + p.w/2) - cx; const dy = (p.y + p.h/2) - cy; return Math.hypot(dx,dy) <= coreRadius; }; if(pl && pm && inCore(pl) && inCore(pm)){ this._bothInCoreTimer = (this._bothInCoreTimer || 0) + dt; if(this._bothInCoreTimer >= 3.0 && this.engine && !this.engine.win){ // final explosion & ending based on decision4 // play particle effect (store flag for Entities.render) this.entities._finalExplode = { x:cx, y:cy, t:1.0 }; // Kronos death if(this.entities._kronos) this.entities._kronos.alive = false; this.engine.win = true; this.engine.paused = true; // overlay text per decision4 let ending = "Сервер спасён."; let color = '#8ef08e'; if(d4 === 1){ ending = "СЕРВЕР СПАСЁН, НО КРОНОС ОСТАВИЛ ГЛУБОКИЕ ОШИБКИ..."; color = '#ff6666'; } else if(d4 === 2){ ending = "СЕРВЕР УДАЛОСЬ СОХРАНИТЬ, ХОТЯ И ПОТЕРИ ВЕЛИКИ."; color = '#ffd966'; } else if(d4 === 3){ ending = "ЯДРО ОЧИЩЕНО! ПОСЛЕДНИЙ СЕРВЕР ВОЗРОЖДЁН!"; color = '#39d3ff'; } const overlayBox = document.getElementById('overlay-box'); if(overlayBox){ overlayBox.style.color = color; overlayBox.style.fontSize = '20px'; overlayBox.style.whiteSpace = 'normal'; overlayBox.textContent = ending + "\n\nИГРА ЗАКОНЧЕНА! [R] — НАЧАТЬ ЗАНОВО"; } } } else { this._bothInCoreTimer = 0; } } // Decay behavior: when stability < 30% accelerate platform disappearance and make gaps more dangerous (engine-level handled elsewhere) if(this.engine && (this.engine.decayTimer / this.engine.decayTimerMax) < 0.30){ // speed up decay of ring shards for(const p of this.entities.platforms){ if(p.decaying) p.decayDuration = Math.max(6, (p.decayDuration || 60) * 0.85); } } // if engine decay reaches zero -> game over handled by engine core loop // schedule next frame only while level 5 active if(this.current === 5){ this._finalLoopFrame = requestAnimationFrame(loop); } }; this._finalLoopFrame = requestAnimationFrame(loop); // Teardown helper if(this._finalLoopTeardown) clearTimeout(this._finalLoopTeardown); this._finalLoopTeardown = ()=>{ if(this._finalLoopFrame) cancelAnimationFrame(this._finalLoopFrame); this._finalLoopFrame = null; }; } // --- New: Level 4 Code Wasteland --- _setupLevel4(){ // Level 4: "Кодовая пустошь" — three sand-code pyramids built from block tiles, cactus, wooden ladder, finish and bottomless pit. const rand = (a,b)=> a + Math.random()*(b-a); // Ground/base (non-decaying) const ground = { x:0, y:580, w:800, h:20, alpha:1, decaying:false, exists:true, startDelay:0, decayDuration:0, _age:0, _started:false }; // Helper: create one sand block (small tile) that can decay and be repaired const sandBlock = (x,y,w=40,h=20,delay=3,dur=70) => ({ x, y, w, h, alpha:1, decaying:true, exists:true, startDelay: delay, decayDuration: dur, _age:0, _started:false, _jitter:0 }); const platforms = [ ground ]; // Build three pyramids by stacking sandBlocks (left:4 steps, center:6 steps, right:5 steps) // Left pyramid (low) const left = { baseX:100, baseY:520, steps:4, baseWidth:200, stepH:28 }; for(let s=0;s<left.steps;s++){ const stepW = left.baseWidth - s*40; const cols = Math.max(1, Math.floor(stepW / 40)); const rowY = left.baseY - s * left.stepH; const rowX = left.baseX + Math.round((left.baseWidth - cols*40)/2); for(let c=0;c<cols;c++){ const bx = rowX + c*40 + s*4; // slight offset per row for a pyramidal look platforms.push(sandBlock(bx, rowY, 40, 20, 4 + s*0.6, 110 - s*8)); } } // Center pyramid (high, NPC on top) const center = { baseX:350, baseY:460, steps:6, baseWidth:200, stepH:36 }; for(let s=0;s<center.steps;s++){ const stepW = center.baseWidth - s*36; const cols = Math.max(1, Math.floor(stepW / 40)); const rowY = center.baseY - s * center.stepH; const rowX = center.baseX + Math.round((center.baseWidth - cols*40)/2); for(let c=0;c<cols;c++){ const bx = rowX + c*40 + s*3; platforms.push(sandBlock(bx, rowY, 40, 20, 3 + s*0.7, 120 - s*9)); } } // Right pyramid (mid, cactus on top) const right = { baseX:600, baseY:420, steps:5, baseWidth:160, stepH:34 }; for(let s=0;s<right.steps;s++){ const stepW = right.baseWidth - s*30; const cols = Math.max(1, Math.floor(stepW / 40)); const rowY = right.baseY - s * right.stepH; const rowX = right.baseX + Math.round((right.baseWidth - cols*40)/2); for(let c=0;c<cols;c++){ const bx = rowX + c*40 + s*2; platforms.push(sandBlock(bx, rowY, 40, 20, 5 + s*0.5, 100 - s*7)); } } // Compute top-of-right coordinates for cactus placement (centered above final step) const rightTopCols = Math.max(1, Math.floor((right.baseWidth - (right.steps-1)*30) / 40)); const rightTopX = right.baseX + Math.round((right.baseWidth - rightTopCols*40)/2) + Math.floor(rightTopCols/2)*40 + (right.steps-1)*2; const rightTopY = right.baseY - (right.steps-1) * right.stepH - 20; // Cactus: tall safe object with spikes (rendered by Entities.render using metadata) const cactus = { x: rightTopX - 30, y: rightTopY - 82, w:60, h:80, spikes:true, safe:true, color:'#2db24a', exists:true }; // Wooden ladder: vertical chain of narrow wooden planks leading up from near cactus to high finish const ladderX = cactus.x + Math.round((cactus.w - 48)/2); const ladderTopY = 80; // finish platform y approx const ladderCount = 7; const ladderSteps = []; for(let i=0;i<ladderCount;i++){ const ly = ladderTopY + i * 30; const plank = { x: ladderX, y: ly, w:48, h:12, alpha:1, decaying:true, exists:true, startDelay: 3 + i*0.5, decayDuration: 110, _age:0, _started:false }; ladderSteps.push(plank); platforms.push(plank); } // Finish: glowing green platform with larger area at top of ladder const finish = { x: ladderX - 20, y: ladderTopY - 36, w:120, h:56, alpha:1, decaying:false, exists:true, isGoal:true, startDelay:0, decayDuration:0, _age:0, _started:false }; platforms.push(finish); // Bottomless pit under ladder (area that causes instant death / game over) const pit = { x: ladderX - 12, y: ladderTopY + 20, w:72, h:480, id:'bottomless_pit' }; // Assign final platform list to entities this.entities.platforms = platforms; // Level background metadata for render: dark green with slow falling code symbols and sand area this.entities._levelBackground = { type: 'codeWasteland', color: '#06160b', sandAreaY: 548, rainDensity: 0.55, symbols: ['0','1','if','{','}',';','!'], theme: 'pyramids' }; // Supplemental metadata used by render and logic this.entities._pyramids = { left, center, right }; this.entities._cactus = cactus; this.entities._ladder = { x: ladderX, topY: ladderTopY, count: ladderCount, stepW:48, stepH:12 }; this.entities._pit = pit; this.entities._finish = finish; // NPCs: decision NPC on top of center pyramid, plus helpers // Compute top-of-center coordinates for NPC placement const centerTopCols = Math.max(1, Math.floor((center.baseWidth - (center.steps-1)*36) / 40)); const centerTopX = center.baseX + Math.round((center.baseWidth - centerTopCols*40)/2) + Math.floor(centerTopCols/2)*40 + (center.steps-1)*3; const centerTopY = center.baseY - (center.steps-1) * center.stepH - 18; this.entities._npcs = [ { id:'npc_choice', x: centerTopX, y: centerTopY, text: "Вершина обломана. Как поступим?\\n1. Сохранить целое\\n2. Удалить дефект\\n3. Переписать модуль", puzzle:true, decision:true, choices:[ { id:1, text:'Сохранить целое' }, { id:2, text:'Удалить дефект' }, { id:3, text:'Переписать модуль' } ], correctChoice: 1, shown:false }, { id:'npc_g1', x: left.baseX + 28, y: left.baseY - 12, text: "Песок нестабилен!", shown:false }, { id:'npc_g2', x: center.baseX + 18, y: center.baseY - 12, text: "Чини ступеньки, Лера.", shown:false } ]; // Gameplay tuning: overall stability ~2.5 minutes (150s) and moderate decay rate if(this.engine){ this.engine.decayTimerMax = 150; this.engine.decayTimer = this.engine.decayTimerMax; this.engine.decayRate = 1.2; this.engine.decision4 = null; } // Place players near left pyramid base to start if(this.entities.players){ this.entities.players.lera.x = left.baseX + 12; this.entities.players.lera.y = left.baseY - 28; this.entities.players.max.x = left.baseX + 44; this.entities.players.max.y = left.baseY - 28; this.entities.players.lera.jumpPower = Math.max(460, this.entities.players.lera.jumpPower || 460); this.entities.players.max.jumpPower = Math.max(460, this.entities.players.max.jumpPower || 460); } // Reset entities so the new level state is active if(typeof this.entities.reset === 'function') this.entities.reset(); // Note: visual details (sand blocks with green glyphs on each block face, cactus spikes, // wooden ladder rails/rungs, finish glow and pit vortex) are implemented by Entities.render // using the metadata we attached above. } // --- Level 3 extras: entrance marker + 2-3 light teleports + simple linking logic --- _setupLevel3Extras(){ // create a simple green entrance marker (40x40) positioned at entrance center try { const root = document.getElementById('game-root'); if(!root || !this.entities || !this.entities._levelBackground) return; const bg = this.entities._levelBackground; const entrance = bg.entrance; if(entrance){ // Entrance marker (green square) const el = document.createElement('div'); el.id = 'lvl3-entrance'; el.style.position = 'absolute'; el.style.left = `${entrance.x + 8}px`; el.style.top = `${entrance.y + 8}px`; el.style.width = '40px'; el.style.height = '40px'; el.style.background = '#21c35b'; el.style.borderRadius = '6px'; el.style.boxShadow = '0 0 12px rgba(33,195,91,0.6)'; el.style.zIndex = 1200; el.style.pointerEvents = 'none'; root.appendChild(el); this._entranceElem = el; // ensure players are near it (already set by _setupLevel3); no further reposition } // Create 3 teleport DOM elements but only use 2-3 of them (as requested, place in two dead-ends and one corridor). const teleports = this.entities._teleports || []; // pick first three teleports for this simple behavior (if less, use available) const useTps = teleports.slice(0,3); this._teleportElems = []; useTps.forEach((tp, i) => { const el = document.createElement('div'); el.className = 'lvl3-teleport'; el.style.position = 'absolute'; // center the element at tp coords relative to game-root (canvas inside) const canvas = document.getElementById('game-canvas'); const rect = canvas.getBoundingClientRect(); const rootRect = document.getElementById('game-root').getBoundingClientRect(); // compute offset relative to game-root const offsetX = tp.x - (this.entities.width/2 - canvas.width/2); // simpler: convert tp coordinates directly as canvas is same size and positioned; we use tp.x/tp.y directly el.style.left = `${Math.round(tp.x - 18)}px`; el.style.top = `${Math.round(tp.y - 18)}px`; el.style.width = '36px'; el.style.height = '36px'; el.style.borderRadius = '50%'; el.style.background = 'radial-gradient(circle at 30% 30%, rgba(192,150,255,0.95), rgba(118,56,191,0.9))'; el.style.boxShadow = '0 0 14px rgba(179,108,255,0.36)'; el.style.zIndex = 1250; el.style.pointerEvents = 'none'; el.style.transform = 'scale(1)'; el.style.transition = 'transform 260ms ease, opacity 260ms ease'; root.appendChild(el); // store mapping this._teleportElems.push({ dom: el, meta: tp }); }); // Link teleports in cycle 1->2,2->3,3->1 for the used set for(let i=0;i<this._teleportElems.length;i++){ const cur = this._teleportElems[i].meta; const nxt = this._teleportElems[(i+1) % this._teleportElems.length].meta; cur._linkedTarget = nxt; } // Start a tight 60 FPS loop to pulse visuals and check collisions (simple distance check) let last = performance.now(); this._tpLoop = ()=>{ const now = performance.now(); const dt = Math.min(1000/60, now - last); last = now; // pulse each teleport this._teleportElems.forEach((t,i)=>{ const el = t.dom; const meta = t.meta; meta.pulse = (meta.pulse || 0) + dt/1000 * (0.9 + i*0.1); const scale = 1 + Math.sin(meta.pulse*2.0) * 0.09; el.style.transform = `scale(${scale})`; // small opacity breathing const opacity = 0.85 + Math.abs(Math.sin(meta.pulse*1.8))*0.15; el.style.opacity = `${opacity}`; }); // check players proximity -> teleport if overlapping const players = this.entities.players || {}; ['lera','max'].forEach(pk=>{ const pl = players[pk]; if(!pl) return; for(const t of this._teleportElems){ const dx = (pl.x + pl.w/2) - t.meta.x; const dy = (pl.y + pl.h/2) - t.meta.y; const dist = Math.hypot(dx,dy); const triggerRadius = (t.meta.r || 14) + 8; if(dist <= triggerRadius){ // teleport player to linked target center (instant) const target = t.meta._linkedTarget; if(target){ pl.x = target.x - pl.w/2; pl.y = target.y - pl.h/2; // small visual flash via engine if(this.engine) this.engine._triggerFlash('green', 140); } } } }); // schedule next frame if still on level 3 and loop active if(this._tpLoop && this.current === 3){ this._tpFrame = requestAnimationFrame(this._tpLoop); } }; // start loop this._tpFrame = requestAnimationFrame(this._tpLoop); } catch(err){ // silently ignore any DOM issues — do not break core gameplay console.warn('level3 extras init failed', err); } } _teardownLevel3Extras(){ // stop any running teleport loop/frame if(this._tpFrame){ cancelAnimationFrame(this._tpFrame); this._tpFrame = null; } this._tpLoop = null; // remove teleport DOM elements if(this._teleportElems && this._teleportElems.length){ for(const t of this._teleportElems){ if(t.dom && t.dom.parentElement) t.dom.parentElement.removeChild(t.dom); } } this._teleportElems = []; // remove entrance element if(this._entranceElem && this._entranceElem.parentElement){ this._entranceElem.parentElement.removeChild(this._entranceElem); } this._entranceElem = null; } _checkWin(){ if(!this.engine) return; if(this.engine.win){ if(this._handlingWin) return; this._handlingWin = true; setTimeout(()=> { this._handlingWin = false; if(this.current < this.total){ this.loadLevel(this.current + 1); } else { const el = document.getElementById('level-title'); if(el) el.textContent = 'Сервер спасён'; if(this._watcher){ clearInterval(this._watcher); this._watcher = null; } } }, 3000); } } restartCurrent(){ this.loadLevel(this.current); } }