/* ========================================================= player.js — физика и состояния пиксельной героини. Физика (прыжок, двойной прыжок с сальто, подкат, падение), конечный автомат состояний и хитбокс. ========================================================= */ 'use strict'; const PlayerState = Object.freeze({ RUN: 'run', JUMP: 'jump', DOUBLE: 'doublejump', SLIDE: 'slide', FALL: 'fall', VICTORY: 'victory', }); class Player { constructor(particles, audio) { this.particles = particles; this.audio = audio; this.reset(); } reset() { const P = CONFIG.PLAYER; this.x = P.X; this.y = CONFIG.VIEW.GROUND_Y; // Y стоп this.vy = 0; this.width = P.WIDTH; this.height = P.HEIGHT; this.state = PlayerState.RUN; this.onGround = true; this.jumpsUsed = 0; this.coyote = 0; // таймер «койота» this.jumpBuffer = 0; // буфер прыжка this.rotation = 0; // угол сальто/падения this.animT = 0; // фаза анимации this.runFrame = 0; // явный кадр бега (0..7) this.runAnimT = 0; // накопитель времени текущего кадра this.sliding = false; this.slidePhase = 'idle'; // idle | enter | hold | exit this.slideAnimT = 0; this.dead = false; this.won = false; this.deathMode = 'normal'; // 'normal' | 'hole' | 'splash' // Сальто двойного прыжка: одно завершённое вращение в воздухе this.flipping = false; this.flipT = 0; this._dustTimer = 0; } /* -------- Входные действия -------- */ requestJump() { if (this.dead || this.won) return; this.jumpBuffer = CONFIG.PLAYER.JUMP_BUFFER; } setSlide(on) { if (this.dead || this.won) return; this.sliding = on; if (!on && (this.slidePhase === 'enter' || this.slidePhase === 'hold')) { this.slidePhase = 'exit'; this.slideAnimT = 0; } } _tryJump() { const P = CONFIG.PLAYER; const canFirst = (this.onGround || this.coyote > 0) && this.jumpsUsed === 0; const canSecond = !this.onGround && this.jumpsUsed === 1 && this.jumpsUsed < P.MAX_JUMPS; if (canFirst) { this.vy = P.JUMP_VELOCITY; this.jumpsUsed = 1; this.onGround = false; this.coyote = 0; this.state = PlayerState.JUMP; this.jumpBuffer = 0; this.audio.jump(); } else if (canSecond) { this.vy = P.DOUBLE_JUMP_VELOCITY; this.jumpsUsed = 2; this.state = PlayerState.DOUBLE; this.jumpBuffer = 0; // Запускаем одно сальто, которое завершится в воздухе this.flipping = true; this.flipT = 0; this.rotation = 0; this.audio.doubleJump(); } } /* -------- Обновление -------- */ update(dt, speed) { if (this.state === PlayerState.VICTORY) { this.animT += dt; return; } if (this.dead) { this._updateDead(dt); return; } const P = CONFIG.PLAYER; // Таймеры буфера/койота if (this.jumpBuffer > 0) this.jumpBuffer -= dt; if (this.coyote > 0) this.coyote -= dt; if (this.jumpBuffer > 0) this._tryJump(); // Подкат возможен только на земле const wantSlide = this.sliding && this.onGround; // Гравитация this.vy += P.GRAVITY * dt; this.y += this.vy * dt; // Приземление if (this.y >= CONFIG.VIEW.GROUND_Y) { this.y = CONFIG.VIEW.GROUND_Y; if (!this.onGround) { this.onGround = true; this.jumpBuffer > 0 ? this._tryJump() : null; this.particles.dust(this.x - 6, this.y, -1, 5); // пыль приземления } this.vy = 0; this.jumpsUsed = 0; this.rotation = 0; this.flipping = false; this.coyote = P.COYOTE_TIME; this.state = wantSlide ? PlayerState.SLIDE : PlayerState.RUN; } else { // В воздухе this.onGround = false; if (this.flipping) { // Ровно один оборот сальто, завершается ещё в полёте. this.flipT += dt / P.FLIP_DURATION; if (this.flipT >= 1) { this.flipT = 1; this.flipping = false; this.rotation = 0; this.state = this.vy > 0 ? PlayerState.FALL : PlayerState.JUMP; } else { this.rotation = this.flipT * Math.PI * 2; // 0 → 360° } } else if (this.vy > 60) { this.state = PlayerState.FALL; } } // Обновление размеров хитбокса под состояние this.height = (this.state === PlayerState.SLIDE) ? P.SLIDE_HEIGHT : P.HEIGHT; // Анимация + эффекты this._updateAnim(dt, speed); } _updateAnim(dt, speed) { const slideFrameTime = CONFIG.PLAYER.SLIDE_FRAME_TIME; if (this.state === PlayerState.SLIDE) { if (this.slidePhase === 'idle' || this.slidePhase === 'exit') { this.slidePhase = 'enter'; this.slideAnimT = 0; } else if (this.slidePhase === 'enter') { this.slideAnimT += dt; if (this.slideAnimT >= slideFrameTime * 4) { this.slidePhase = 'hold'; this.slideAnimT = 0; } } } else if (this.slidePhase === 'exit') { this.slideAnimT += dt; if (this.slideAnimT >= slideFrameTime * 3) { this.slidePhase = 'idle'; this.slideAnimT = 0; } } else if (this.slidePhase === 'enter' || this.slidePhase === 'hold') { this.slidePhase = 'exit'; this.slideAnimT = 0; } if (this.state === PlayerState.RUN) { // Отдельный покадровый таймер: каждый из восьми кадров гарантированно // показывается хотя бы RUN_FRAME_TIME, без зависимости от render FPS. const frameTime = CONFIG.PLAYER.RUN_FRAME_TIME; this.runAnimT += dt; while (this.runAnimT >= frameTime) { this.runAnimT -= frameTime; this.runFrame = (this.runFrame + 1) % 8; } this.animT += dt; // Редкий след пыли при беге this._dustTimer -= dt; if (this._dustTimer <= 0) { this.particles.dust(this.x - 12, this.y, -1, 1); this._dustTimer = 0.16; } } else if (this.state === PlayerState.SLIDE) { // Активная пыль и частицы, летящие влево из-под подката this._dustTimer -= dt; if (this._dustTimer <= 0) { this.particles.slideDust(this.x - 16, this.y); this._dustTimer = 0.045; } } else { this.animT += dt; } } _updateDead(dt) { if (this.deathMode === 'hole') { // Проваливание в люк: уходит вертикально вниз, без отскока. this.vy += CONFIG.PLAYER.GRAVITY * dt; this.y += this.vy * dt; this.rotation += dt * 1.5; // Не ограничиваем полом — героиня «уходит» под землю в люк. } else { // Обычное падение с вращением (небольшой отскок от удара). this.vy += CONFIG.PLAYER.GRAVITY * 0.7 * dt; this.y += this.vy * dt; this.rotation += dt * 6; const floor = CONFIG.VIEW.GROUND_Y + 10; if (this.y > floor) { this.y = floor; this.vy = 0; } } } /* -------- Состояния конца игры -------- */ die(mode = 'normal') { if (this.dead) return; this.dead = true; this.deathMode = mode; this.state = PlayerState.FALL; if (mode === 'hole') { this.vy = 120; // сразу проваливается вниз this.height = CONFIG.PLAYER.HEIGHT; } else { this.vy = -260; // подпрыгивает от удара } this.audio.hit(); } celebrate() { this.won = true; this.state = PlayerState.VICTORY; this.y = CONFIG.VIEW.GROUND_Y; this.rotation = 0; this.animT = 0; } /* -------- Хитбокс (чуть меньше спрайта — честнее к игроку) -------- */ getHitbox() { const pad = CONFIG.PLAYER.HITBOX_PADDING; return { x: this.x - this.width / 2 + pad, y: this.y - this.height + pad, w: this.width - pad * 2, h: this.height - pad, }; } /* -------- Рендер -------- */ _slideFrame() { const t = CONFIG.PLAYER.SLIDE_FRAME_TIME; if (this.slidePhase === 'enter') return Math.min(3, Math.floor(this.slideAnimT / t)); if (this.slidePhase === 'hold') return 4; if (this.slidePhase === 'exit') return Math.min(7, 5 + Math.floor(this.slideAnimT / t)); return 7; } draw(ctx, debugHitbox = false) { // Приводим внутреннее состояние к позе рендерера. let pose; if (this.dead || this.state === PlayerState.FALL) pose = 'fall'; else if (this.state === PlayerState.DOUBLE || this.state === PlayerState.JUMP) pose = 'jump'; else if (this.state === PlayerState.SLIDE || (this.state === PlayerState.RUN && this.slidePhase === 'exit')) pose = 'slide'; else if (this.state === PlayerState.VICTORY) pose = 'victory'; else pose = 'run'; let frame; if (pose === 'run') frame = this.runFrame; else if (pose === 'slide') frame = this._slideFrame(); else if (pose === 'victory') frame = 7; else if (this.dead) frame = 6; else { const jumpProgress = Utils.clamp((this.vy + 900) / 1800, 0, 0.9999); frame = Math.floor(jumpProgress * 8); } const st = { state: pose, frame, animT: this.animT, rotation: this.rotation, flip: false }; CharacterRenderer.draw(ctx, this.x, this.y, st); if (debugHitbox) { const hb = this.getHitbox(); ctx.strokeStyle = 'rgba(255,0,0,0.8)'; ctx.strokeRect(hb.x, hb.y, hb.w, hb.h); } } }