/* ========================================================= game.js — ядро: конечный автомат состояний, игровой цикл с фиксированным шагом, адаптивное масштабирование canvas. ========================================================= */ 'use strict'; const GameState = Object.freeze({ MENU: 'menu', PLAYING: 'playing', PAUSED: 'paused', DYING: 'dying', // проигрывается анимация падения GAMEOVER: 'gameover', FINALE: 'finale', // добегание до Госдумы WIN: 'win', }); class Game { constructor() { this.canvas = document.getElementById('game-canvas'); this.ctx = this.canvas.getContext('2d'); this.ctx.imageSmoothingEnabled = false; // Подсистемы this.audio = new AudioManager(); this.input = new InputManager(); this.ui = new UI(this.audio); this.particles = new ParticleSystem(); this.background = new Background(); this.player = new Player(this.particles, this.audio); this.entities = new EntityManager(this.particles); this.state = GameState.MENU; // Параметры кадра/масштаба this.renderScale = 1; this.offX = 0; this.offY = 0; this._acc = 0; this._last = 0; this.fixedDt = 1 / CONFIG.TARGET_FPS; this._initRunState(); this._bindUI(); this._bindInputEvents(); window.addEventListener('resize', () => this.resize()); this.resize(); } /* Сбрасываемое состояние забега. */ _initRunState() { this.speed = CONFIG.SPEED.START; this.worldDistance = 0; // px this.score = 0; this.coinsCollected = 0; this.districtsWon = 0; this.districtQueue = Utils.shuffle(DISTRICTS); // случайный порядок без повторов this.dyingTimer = 0; this.darken = 0; this._pendingCaption = ''; // Финал this.finalePhase = null; // 'clear' | 'approach' | 'walk' | 'enter' this.buildingActive = false; this.buildingX = CONFIG.VIEW.WIDTH + 200; this.playerAlpha = 1; this.clearTimer = 0; this.enterTimer = 0; this._holeRect = null; // область отверстия люка для обрезки this._holeObstacle = null; // сам люк (перерисовываем поверх — «проглатывает») } /* ---------------- Масштабирование ---------------- */ resize() { const shell = document.getElementById('game-frame'); const dpr = window.devicePixelRatio || 1; const cssW = shell.clientWidth, cssH = shell.clientHeight; this._shellW = cssW; this._shellH = cssH; this.canvas.width = Math.round(cssW * dpr); this.canvas.height = Math.round(cssH * dpr); this.canvas.style.width = cssW + 'px'; this.canvas.style.height = cssH + 'px'; const V = CONFIG.VIEW; // Мир заполняет окно целиком (cover): при округлении размеров рамки // до целых пикселей иначе остаётся тёмная полоска по краю. const scale = Math.max(cssW / V.WIDTH, cssH / V.HEIGHT); this.renderScale = scale * dpr; this.offX = (cssW - V.WIDTH * scale) * dpr / 2; this.offY = (cssH - V.HEIGHT * scale) * dpr / 2; this.ctx.imageSmoothingEnabled = false; } /* ---------------- Привязка кнопок интерфейса ---------------- */ _bindUI() { const UI_BTN = '.pix-btn,.pix-toggle,#btn-pause,#btn-sound'; // Клик по кнопкам интерфейса document.body.addEventListener('click', (e) => { const btn = e.target.closest('[data-action],[data-toggle]'); if (!btn) return; this.audio.resume(); const action = btn.dataset.action; const toggle = btn.dataset.toggle; if (action) this._handleAction(action); if (toggle) this._handleToggle(toggle, btn); }); // Звук нажатия для всех кнопок интерфейса (кроме игровых экранных) document.body.addEventListener('pointerdown', (e) => { const btn = e.target.closest(UI_BTN); if (btn) { this.audio.resume(); this.audio.button(); } }); // Звук наведения мыши document.body.addEventListener('mouseover', (e) => { const btn = e.target.closest(UI_BTN); if (btn && btn !== this._hoverEl) { this._hoverEl = btn; this.audio.hover(); } }); document.body.addEventListener('mouseout', (e) => { const btn = e.target.closest(UI_BTN); if (btn && btn === this._hoverEl) this._hoverEl = null; }); // Кнопка паузы в HUD document.getElementById('btn-pause').addEventListener('click', () => { if (this.state === GameState.PLAYING) this.pause(); }); // Общий звук в HUD: музыка и эффекты выключаются одной кнопкой. const soundBtn = document.getElementById('btn-sound'); if (soundBtn) { soundBtn.addEventListener('click', () => { const soundOn = this.audio.toggleMute(); soundBtn.classList.toggle('is-muted', !soundOn); soundBtn.setAttribute('aria-pressed', String(!soundOn)); soundBtn.setAttribute('aria-label', soundOn ? 'Выключить звук' : 'Включить звук'); soundBtn.title = soundOn ? 'Звук включён' : 'Звук выключен'; }); } // Громкость const vol = document.getElementById('volume-range'); if (vol) vol.addEventListener('input', () => this.audio.setVolume(vol.value / 100)); } _handleAction(action) { switch (action) { case 'play': this.startGame(); break; case 'settings': this.ui.showSettings(); break; case 'howto': this.ui.showHowto(); break; case 'back-menu': this.ui.showMenu(); break; case 'resume': this.resume(); break; case 'restart': this.startGame(); break; case 'to-menu': this.toMenu(); break; } } _handleToggle(toggle, btn) { if (toggle === 'music') { const on = this.audio.toggleMusic(); this._syncToggleButtons('music', on); } else if (toggle === 'sfx') { const on = this.audio.toggleSfx(); this._syncToggleButtons('sfx', on); } } /* Синхронизировать состояние всех кнопок данного тумблера. */ _syncToggleButtons(kind, on) { const label = kind === 'music' ? 'МУЗЫКА' : 'ЗВУКИ'; document.querySelectorAll('[data-toggle="' + kind + '"]').forEach(b => { b.classList.toggle('off', !on); // У иконочных кнопок подпись не трогаем — там нарисованная иконка. if (b.classList.contains('pix-btn--icon')) { b.setAttribute('aria-pressed', String(on)); b.title = (kind === 'music' ? 'Музыка' : 'Звуки') + ': ' + (on ? 'ВКЛ' : 'ВЫКЛ'); return; } // Тумблеры в настройках — короткая подпись. b.textContent = b.classList.contains('pix-toggle') ? (on ? 'ВКЛ' : 'ВЫКЛ') : label + ': ' + (on ? 'ВКЛ' : 'ВЫКЛ'); }); } _bindInputEvents() { this.input.on('jump', () => { if (this.state === GameState.PLAYING) this.player.requestJump(); }); this.input.on('pause', () => { if (this.state === GameState.PLAYING) this.pause(); else if (this.state === GameState.PAUSED) this.resume(); }); this.input.on('slideStart', () => { if (this.state === GameState.PLAYING) this.player.setSlide(true); }); this.input.on('slideEnd', () => this.player.setSlide(false)); } /* ---------------- Управление состояниями ---------------- */ startGame() { this._initRunState(); this.player.reset(); this.entities.reset(); this.background.reset(); this.particles.clear(); this.ui.resetSupportBar(); this.ui.showGameplay(); this.audio.resume(); this.audio.setGamePaused(false); this.audio.restartMusic(); // новый забег — песня с начала this.state = GameState.PLAYING; } pause() { if (this.state !== GameState.PLAYING) return; this.state = GameState.PAUSED; this.audio.setGamePaused(true); // музыка молчит всю паузу (позиция сохраняется) this.ui.showPause(); } resume() { if (this.state !== GameState.PAUSED) return; this.state = GameState.PLAYING; this.audio.setGamePaused(false); this.audio.playMusic(); // продолжаем с того же места this.ui.hidePause(); } toMenu() { this.state = GameState.MENU; this.ui.hidePause(); this.ui.showMenu(); this.audio.setGamePaused(false); this.audio.stopMusic(); } _onHit(obstacle) { const id = obstacle.type.id; // Особые анимации столкновения let mode = 'normal'; if (id === 'hatch') { mode = 'hole'; this.dyingTimer = 1.3; // Совмещаем героиню с центром люка, обрезаем по колонке отверстия и // перерисовываем люк поверх — она «проваливается» и исчезает в чёрном. const cx = obstacle.centerX(); this.player.x = cx; this._holeObstacle = obstacle; this._holeRect = { x: cx - 36, y: 0, w: 72, h: CONFIG.VIEW.GROUND_Y + 18 }; } else if (id === 'puddle') { mode = 'splash'; this.particles.splash(this.player.x, CONFIG.VIEW.GROUND_Y); // брызги this.dyingTimer = 1.1; } else { this.dyingTimer = 1.1; } this.player.die(mode); this.state = GameState.DYING; const caps = LOSE_CAPTIONS[id] || ['Не в этот раз.']; this._pendingCaption = Utils.pick(caps); } _awardDistrict() { if (this.districtsWon >= CONFIG.DISTRICTS_TOTAL) return; const name = this.districtQueue[this.districtsWon]; this.ui.fillSupportCell(this.districtsWon); this.districtsWon++; this.ui.showDistrictPopup(name); this.audio.district(); if (this.districtsWon >= CONFIG.DISTRICTS_TOTAL) this._startFinale(); } _startFinale() { this.state = GameState.FINALE; this.finalePhase = 'clear'; // сначала «пустая дорога» this.entities.stopSpawning(); this.buildingActive = false; this.playerAlpha = 1; this.clearTimer = CONFIG.FINALE.CLEAR_MIN_TIME; } _win() { this.state = GameState.WIN; this.player.celebrate(); this.audio.win(); this.ui.showWin(); } /* ---------------- Игровой цикл ---------------- */ start() { this._last = performance.now(); const loop = (now) => { let frameTime = (now - this._last) / 1000; this._last = now; if (frameTime > 0.25) frameTime = 0.25; // защита от «прыжка» после сворачивания this._acc += frameTime; // Фиксированный шаг физики → стабильные 60 FPS-ощущения while (this._acc >= this.fixedDt) { this.update(this.fixedDt); this._acc -= this.fixedDt; } this.render(); this._syncLayout(); requestAnimationFrame(loop); }; requestAnimationFrame(loop); } /** * Кадровая сверка интерфейса: размер окна игры мог измениться без события * resize (например, в меню резервируется место под выходными данными), а * ползунки полос прокрутки должны следовать за содержимым без задержки. */ _syncLayout() { const shell = document.getElementById('game-frame'); if (shell.clientWidth !== this._shellW || shell.clientHeight !== this._shellH) this.resize(); if (typeof ScrollRail !== 'undefined') ScrollRail.syncAll(); } update(dt) { switch (this.state) { case GameState.PLAYING: this._updatePlaying(dt); break; case GameState.DYING: this._updateDying(dt); break; case GameState.FINALE: this._updateFinale(dt); break; case GameState.WIN: this.player.update(dt, 0); this.particles.update(dt); this.particles.confetti(CONFIG.VIEW.WIDTH); break; case GameState.MENU: this.particles.update(dt); break; default: break; // PAUSED / GAMEOVER заморожены } } _updatePlaying(dt) { // Разгон и рост сложности this.speed = Utils.clamp(this.speed + CONFIG.SPEED.ACCEL * dt, CONFIG.SPEED.START, CONFIG.SPEED.MAX); const dx = this.speed * dt; this.worldDistance += dx; this.background.update(dx, dt); this.player.update(dt, this.speed); const res = this.entities.update(dt, this.speed, this.player); this.particles.update(dt); // Монеты → счёт и районы if (res.collectedCoins > 0) { this.audio.coin(); this.score += res.collectedCoins * CONFIG.COINS.SCORE_VALUE; this.coinsCollected += res.collectedCoins; while (this.coinsCollected >= (this.districtsWon + 1) * CONFIG.COINS.PER_DISTRICT && this.districtsWon < CONFIG.DISTRICTS_TOTAL) { this._awardDistrict(); } } // Столкновение if (res.hitObstacle) { this._onHit(res.hitObstacle); return; } // Счёт растёт с прежней скоростью (не привязан к «медленному» метражу) this.score = Math.max(this.score, Math.floor(this.worldDistance / CONFIG.SCORE.DISTANCE_DIVISOR) * CONFIG.SCORE.PER_METER + this.coinsCollected * CONFIG.COINS.SCORE_VALUE); this.ui.updateHUD(this._hudState()); } _updateDying(dt) { this.player.update(dt, 0); this.particles.update(dt); this.darken = Utils.clamp(this.darken + dt * 1.2, 0, 0.65); this.dyingTimer -= dt; if (this.dyingTimer <= 0) { this.state = GameState.GAMEOVER; this.ui.showGameOver(this._pendingCaption, { score: this.score, distance: Math.floor(this.worldDistance / CONFIG.SPEED.DISTANCE_PER_UNIT), districts: this.districtsWon, }); } } _updateFinale(dt) { const F = CONFIG.FINALE; const doorScreenX = () => this.buildingX + 170; // центр = дверь const cruise = CONFIG.SPEED.MAX * 0.5; switch (this.finalePhase) { case 'clear': { // Бежим дальше, спавн выключен — оставшиеся препятствия уезжают. const dx = cruise * dt; this.worldDistance += dx; this.background.update(dx, dt); this.player.update(dt, cruise); this.entities.update(dt, cruise, null); // без столкновений — победный забег this.particles.update(dt); this.clearTimer -= dt; // Когда дорога пуста и прошло минимум времени — выводим Госдуму. if (this.clearTimer <= 0 && this.entities.obstacles.length === 0) { this.finalePhase = 'approach'; this.buildingActive = true; this.buildingX = CONFIG.VIEW.WIDTH + 60; } break; } case 'approach': { // Здание въезжает вместе с миром, пока дверь не встанет на место. const dx = cruise * dt; this.worldDistance += dx; this.background.update(dx, dt); this.player.update(dt, cruise); this.entities.update(dt, cruise, null); this.particles.update(dt); this.buildingX -= dx; if (doorScreenX() <= F.BUILDING_DOOR_X) { this.buildingX = F.BUILDING_DOOR_X - 170; this.finalePhase = 'walk'; } break; } case 'walk': { // Мир стоит; героиня спокойно доходит до двери. this.player.update(dt, cruise); // анимация бега продолжается this.particles.update(dt); const target = doorScreenX() - 30; this.player.x = Utils.approach(this.player.x, target, 5, dt); if (Math.abs(this.player.x - target) < 4) { this.finalePhase = 'enter'; this.enterTimer = F.ENTER_TIME; } break; } case 'enter': { // Входит в дверь и мягко «растворяется» внутри здания. this.player.update(dt, cruise); this.particles.update(dt); this.enterTimer -= dt; this.player.x += 24 * dt; this.playerAlpha = Utils.clamp(this.enterTimer / F.ENTER_TIME, 0, 1); if (this.enterTimer <= 0) { this.playerAlpha = 0; this._win(); } break; } } this.ui.updateHUD(this._hudState()); } _hudState() { return { score: this.score, distance: Math.floor(this.worldDistance / CONFIG.SPEED.DISTANCE_PER_UNIT), speed: this.speed, districts: this.districtsWon, }; } /* ---------------- Рендер ---------------- */ render() { const ctx = this.ctx; // Очистка всего устройства-холста (включая letterbox-поля) ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#05080c'; ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); // Виртуальная система координат 960x540 ctx.setTransform(this.renderScale, 0, 0, this.renderScale, this.offX, this.offY); ctx.imageSmoothingEnabled = false; // Всё рисуем строго внутри игрового поля: слои фона рисуются с запасом // за краями, и без обрезки они выступали бы за границу области. ctx.save(); ctx.beginPath(); ctx.rect(0, 0, CONFIG.VIEW.WIDTH, CONFIG.VIEW.HEIGHT); ctx.clip(); // Мир this.background.draw(ctx); // Здание Госдумы (на финале и победе) if (this.buildingActive) { this.background.drawDumaBuilding(ctx, this.buildingX); } // Сущности рисуем всегда, кроме чистого меню if (this.state !== GameState.MENU) { this.entities.draw(ctx); if (this.playerAlpha > 0) { ctx.globalAlpha = this.playerAlpha; const clip = (this.player.deathMode === 'hole') && this._holeRect; if (clip) { // Обрезаем героиню по колонке отверстия (не вылезает за края люка). ctx.save(); ctx.beginPath(); ctx.rect(this._holeRect.x, this._holeRect.y, this._holeRect.w, this._holeRect.h); ctx.clip(); this.player.draw(ctx); ctx.restore(); // Перерисовываем сам люк поверх — он «проглатывает» героиню в чёрное отверстие. if (this._holeObstacle) this._holeObstacle.draw(ctx); } else { this.player.draw(ctx); } ctx.globalAlpha = 1; } } this.particles.draw(ctx); // Затемнение при гибели if (this.darken > 0 && (this.state === GameState.DYING || this.state === GameState.GAMEOVER)) { ctx.fillStyle = 'rgba(5,8,12,' + this.darken + ')'; ctx.fillRect(0, 0, CONFIG.VIEW.WIDTH, CONFIG.VIEW.HEIGHT); } ctx.restore(); // снимаем обрезку по игровому полю } }