AVALANCHE

ИДЕИ
В РЕАЛЬНЫЕ
ПРОДУКТЫ.

function main(canvas, options = {}) {
  const game = createGame(options);
  if (!canvas) return game;
  const view = mountGame(canvas, game);
  return { ...game, destroy: view.destroy };
}

// Board geometry includes two hidden spawn rows.
const WIDTH = 10;
const HEIGHT = 22;
const HIDDEN_ROWS = 2;
const LOCK_DELAY = 500;
const MAX_LOCK_RESETS = 15;
const LINE_POINTS = [0, 100, 300, 500, 800];
const TYPES = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
const COLORS = {
  I: '#67e8f9', O: '#fde68a', T: '#c4b5fd',
  S: '#86efac', Z: '#fda4af', J: '#93c5fd', L: '#fdba74',
};
const SHAPES = {
  I: [[0,0,0,0], [1,1,1,1], [0,0,0,0], [0,0,0,0]],
  O: [[1,1], [1,1]],
  T: [[0,1,0], [1,1,1], [0,0,0]],
  S: [[0,1,1], [1,1,0], [0,0,0]],
  Z: [[1,1,0], [0,1,1], [0,0,0]],
  J: [[1,0,0], [1,1,1], [0,0,0]],
  L: [[0,0,1], [1,1,1], [0,0,0]],
};

// SRS kick offsets use screen coordinates: positive y is down.
const KICKS = {
  '0>1': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
  '1>0': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
  '1>2': [[0,0],[1,0],[1,1],[0,-2],[1,-2]],
  '2>1': [[0,0],[-1,0],[-1,-1],[0,2],[-1,2]],
  '2>3': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
  '3>2': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
  '3>0': [[0,0],[-1,0],[-1,1],[0,-2],[-1,-2]],
  '0>3': [[0,0],[1,0],[1,-1],[0,2],[1,2]],
};
const I_KICKS = {
  '0>1': [[0,0],[-2,0],[1,0],[-2,1],[1,-2]],
  '1>0': [[0,0],[2,0],[-1,0],[2,-1],[-1,2]],
  '1>2': [[0,0],[-1,0],[2,0],[-1,-2],[2,1]],
  '2>1': [[0,0],[1,0],[-2,0],[1,2],[-2,-1]],
  '2>3': [[0,0],[2,0],[-1,0],[2,-1],[-1,2]],
  '3>2': [[0,0],[-2,0],[1,0],[-2,1],[1,-2]],
  '3>0': [[0,0],[1,0],[-2,0],[1,2],[-2,-1]],
  '0>3': [[0,0],[-1,0],[2,0],[-1,-2],[2,1]],
};

function createGame(options = {}) {
  const random = options.random || Math.random;
  const listeners = new Set();
  let board;
  let active;
  let queue;
  let held;
  let holdUsed;
  let score;
  let lines;
  let level;
  let combo;
  let backToBack;
  let over;
  let paused;
  let gravityElapsed;
  let lockElapsed;
  let lockResets;

  function reset() {
    board = emptyBoard();
    queue = [];
    held = null;
    holdUsed = false;
    score = 0;
    lines = 0;
    level = 1;
    combo = -1;
    backToBack = false;
    over = false;
    paused = false;
    gravityElapsed = 0;
    lockElapsed = 0;
    lockResets = 0;
    refillQueue();
    spawn();
    notify();
  }

  function refillQueue() {
    while (queue.length < 7) {
      const bag = [...TYPES];
      for (let i = bag.length - 1; i > 0; i--) {
        const j = Math.floor(random() * (i + 1));
        [bag[i], bag[j]] = [bag[j], bag[i]];
      }
      queue.push(...bag);
    }
  }

  function spawn(type) {
    refillQueue();
    const nextType = type || queue.shift();
    active = {
      type: nextType,
      shape: cloneMatrix(SHAPES[nextType]),
      x: Math.floor((WIDTH - SHAPES[nextType].length) / 2),
      y: 0,
      rotation: 0,
    };
    gravityElapsed = 0;
    lockElapsed = 0;
    lockResets = 0;
    refillQueue();
    if (collides(board, active)) over = true;
  }

  function playable() {
    return !over && !paused;
  }

  function grounded() {
    return collides(board, { ...active, y: active.y + 1 });
  }

  function replacePiece(next, resetLock = true) {
    if (collides(board, next)) return false;
    const wasGrounded = grounded();
    active = next;
    if (resetLock && wasGrounded && lockResets < MAX_LOCK_RESETS) {
      lockElapsed = 0;
      lockResets++;
    }
    return true;
  }

  function move(dx, dy = 0) {
    if (!playable()) return false;
    const next = { ...active, x: active.x + dx, y: active.y + dy };
    const moved = replacePiece(next);
    if (moved) notify();
    return moved;
  }

  function rotate(direction = 1) {
    if (!playable() || active.type === 'O') return false;
    const from = active.rotation;
    const to = (from + direction + 4) % 4;
    const shape = rotateMatrix(active.shape, direction);
    const table = active.type === 'I' ? I_KICKS : KICKS;
    const offsets = table[from + '>' + to];
    for (const [dx, dy] of offsets) {
      const next = {
        ...active, shape, rotation: to,
        x: active.x + dx, y: active.y + dy,
      };
      if (replacePiece(next)) {
        notify();
        return true;
      }
    }
    return false;
  }

  function softDrop() {
    if (!playable()) return false;
    const moved = replacePiece({ ...active, y: active.y + 1 }, false);
    if (moved) {
      score++;
      gravityElapsed = 0;
      notify();
    }
    return moved;
  }

  function hardDrop() {
    if (!playable()) return 0;
    let distance = 0;
    while (!grounded()) {
      active.y++;
      distance++;
    }
    score += distance * 2;
    lockPiece();
    notify();
    return distance;
  }

  function hold() {
    if (!playable() || holdUsed) return false;
    const previous = held;
    held = active.type;
    spawn(previous);
    holdUsed = true;
    notify();
    return true;
  }

  function lockPiece() {
    const occupied = pieceCells(active);
    if (occupied.some(([, y]) => y < 0)) {
      over = true;
      return;
    }
    for (const [x, y] of occupied) board[y][x] = active.type;
    if (occupied.every(([, y]) => y < HIDDEN_ROWS)) {
      over = true;
      return;
    }
    clearLines();
    holdUsed = false;
    spawn();
  }

  function clearLines() {
    const remaining = board.filter(row => row.some(cell => !cell));
    const cleared = HEIGHT - remaining.length;
    if (cleared === 0) {
      combo = -1;
      return;
    }
    combo++;
    const tetris = cleared === 4;
    const multiplier = tetris && backToBack ? 1.5 : 1;
    score += LINE_POINTS[cleared] * level * multiplier;
    score += Math.max(combo, 0) * 50 * level;
    backToBack = tetris;
    lines += cleared;
    level = 1 + Math.floor(lines / 10);
    board = Array.from({ length: cleared }, emptyRow).concat(remaining);
  }

  function tick(delta = 16) {
    if (!playable()) return;
    const elapsed = Math.min(Math.max(delta, 0), 100);
    gravityElapsed += elapsed;
    const interval = Math.max(60, 1000 * Math.pow(0.8, level - 1));
    while (gravityElapsed >= interval) {
      gravityElapsed -= interval;
      if (!grounded()) active.y++;
    }
    if (grounded()) {
      lockElapsed += elapsed;
      if (lockElapsed >= LOCK_DELAY) lockPiece();
    } else {
      lockElapsed = 0;
    }
    notify();
  }

  function pause() {
    if (over) return false;
    paused = !paused;
    notify();
    return paused;
  }

  function getState() {
    const ghost = { ...active, shape: cloneMatrix(active.shape) };
    while (!collides(board, { ...ghost, y: ghost.y + 1 })) ghost.y++;
    return {
      board: cloneMatrix(board),
      active: { ...active, shape: cloneMatrix(active.shape) },
      ghost, next: queue.slice(0, 5), held,
      score, lines, level, combo, over, paused, holdUsed,
    };
  }

  function notify() {
    if (listeners.size === 0) return;
    const state = getState();
    for (const listener of listeners) listener(state);
  }

  function subscribe(listener) {
    listeners.add(listener);
    listener(getState());
    return () => listeners.delete(listener);
  }

  reset();
  return {
    tick, move, rotate, softDrop, hardDrop, hold,
    pause, reset, getState, subscribe,
  };
}

function emptyRow() {
  return Array(WIDTH).fill(null);
}

function emptyBoard() {
  return Array.from({ length: HEIGHT }, emptyRow);
}

function cloneMatrix(matrix) {
  return matrix.map(row => [...row]);
}

function rotateMatrix(matrix, direction) {
  const size = matrix.length;
  return Array.from({ length: size }, (_, y) =>
    Array.from({ length: size }, (_, x) =>
      direction > 0 ? matrix[size - 1 - x][y] : matrix[x][size - 1 - y]));
}

function pieceCells(piece) {
  const cells = [];
  piece.shape.forEach((row, dy) => {
    row.forEach((value, dx) => {
      if (value) cells.push([piece.x + dx, piece.y + dy]);
    });
  });
  return cells;
}

function collides(board, piece) {
  return pieceCells(piece).some(([x, y]) =>
    x < 0 || x >= WIDTH || y >= HEIGHT || (y >= 0 && board[y][x]));
}

// Canvas rendering is optional; the engine also runs without a DOM.
function mountGame(canvas, game) {
  const context = canvas.getContext('2d');
  if (!context) throw new Error('A 2D canvas is required.');
  const cell = 24;
  const width = WIDTH * cell;
  const height = (HEIGHT - HIDDEN_ROWS) * cell + 104;
  const scale = globalThis.devicePixelRatio || 1;
  canvas.width = width * scale;
  canvas.height = height * scale;
  canvas.style.width = width + 'px';
  canvas.style.height = height + 'px';
  canvas.tabIndex = 0;
  canvas.setAttribute('aria-label', 'Tetris game');
  context.scale(scale, scale);
  let frame;
  let previousTime;
  let destroyed = false;

  function drawBlock(x, y, type, opacity = 1) {
    if (y < HIDDEN_ROWS) return;
    const top = (y - HIDDEN_ROWS) * cell;
    context.globalAlpha = opacity;
    context.fillStyle = COLORS[type];
    context.fillRect(x * cell + 1, top + 1, cell - 2, cell - 2);
    context.fillStyle = 'rgba(255,255,255,0.2)';
    context.fillRect(x * cell + 2, top + 2, cell - 4, 2);
    context.globalAlpha = 1;
  }

  function drawPiece(piece, opacity = 1) {
    for (const [x, y] of pieceCells(piece)) {
      drawBlock(x, y, piece.type, opacity);
    }
  }

  function drawGrid() {
    context.strokeStyle = 'rgba(255,255,255,0.06)';
    context.lineWidth = 1;
    context.beginPath();
    for (let x = 0; x <= WIDTH; x++) {
      context.moveTo(x * cell, 0);
      context.lineTo(x * cell, height - 104);
    }
    for (let y = 0; y <= HEIGHT - HIDDEN_ROWS; y++) {
      context.moveTo(0, y * cell);
      context.lineTo(width, y * cell);
    }
    context.stroke();
  }

  function drawPreview(type, left, top) {
    if (!type) return;
    SHAPES[type].forEach((row, y) => {
      row.forEach((value, x) => {
        if (!value) return;
        context.fillStyle = COLORS[type];
        context.fillRect(left + x * 8, top + y * 8, 7, 7);
      });
    });
  }

  function drawStatus(state) {
    const top = height - 88;
    context.fillStyle = '#cbd5e1';
    context.font = '12px monospace';
    context.fillText('SCORE ' + state.score, 8, top);
    context.fillText('LEVEL ' + state.level, 8, top + 20);
    context.fillText('LINES ' + state.lines, 8, top + 40);
    context.fillText('HOLD', width - 56, top);
    drawPreview(state.held, width - 52, top + 12);
    context.fillText('NEXT', 104, top);
    state.next.slice(0, 3).forEach((type, index) => {
      drawPreview(type, 102 + index * 38, top + 12);
    });
  }

  function drawOverlay(label) {
    const fieldHeight = height - 104;
    context.fillStyle = 'rgba(11,15,16,0.82)';
    context.fillRect(0, 0, width, fieldHeight);
    context.fillStyle = '#f8fafc';
    context.font = 'bold 22px monospace';
    context.textAlign = 'center';
    context.fillText(label, width / 2, fieldHeight / 2);
    context.font = '11px monospace';
    const hint = label === 'PAUSED' ? 'P to resume' : 'Enter to restart';
    context.fillText(hint, width / 2, fieldHeight / 2 + 28);
    context.textAlign = 'left';
  }

  function render(state) {
    context.clearRect(0, 0, width, height);
    context.fillStyle = '#0b0f10';
    context.fillRect(0, 0, width, height);
    drawGrid();
    state.board.forEach((row, y) => {
      row.forEach((type, x) => {
        if (type) drawBlock(x, y, type);
      });
    });
    if (!state.over) {
      drawPiece(state.ghost, 0.18);
      drawPiece(state.active);
    }
    drawStatus(state);
    if (state.paused) drawOverlay('PAUSED');
    if (state.over) drawOverlay('GAME OVER');
  }

  function onKey(event) {
    const actions = {
      ArrowLeft: () => game.move(-1),
      ArrowRight: () => game.move(1),
      ArrowDown: game.softDrop,
      ArrowUp: () => game.rotate(1),
      KeyX: () => game.rotate(1),
      KeyZ: () => game.rotate(-1),
      Space: game.hardDrop,
      KeyC: game.hold,
      KeyP: game.pause,
      Escape: game.pause,
      Enter: game.reset,
    };
    const action = actions[event.code];
    if (!action) return;
    event.preventDefault();
    if (event.repeat && ['Space', 'KeyC', 'Enter'].includes(event.code)) return;
    action();
  }

  function focusCanvas() {
    canvas.focus();
  }

  function animate(time) {
    if (destroyed) return;
    const delta = previousTime === undefined ? 0 : time - previousTime;
    previousTime = time;
    game.tick(delta);
    frame = requestAnimationFrame(animate);
  }

  // Suspend gravity while the browser tab is hidden.
  let resumeOnVisible = false;
  function onVisibilityChange() {
    if (document.hidden) {
      resumeOnVisible = !game.getState().paused && !game.getState().over;
      if (resumeOnVisible) game.pause();
    } else if (resumeOnVisible) {
      if (game.getState().paused) game.pause();
      resumeOnVisible = false;
      previousTime = undefined;
    }
  }
  document.addEventListener('visibilitychange', onVisibilityChange);

  const unsubscribe = game.subscribe(render);
  canvas.addEventListener('keydown', onKey);
  canvas.addEventListener('pointerdown', focusCanvas);
  frame = requestAnimationFrame(animate);

  function destroy() {
    if (destroyed) return;
    destroyed = true;
    cancelAnimationFrame(frame);
    canvas.removeEventListener('keydown', onKey);
    canvas.removeEventListener('pointerdown', focusCanvas);
    document.removeEventListener('visibilitychange', onVisibilityChange);
    unsubscribe();
  }

  return { destroy };
}

// Keyboard: arrows to move, Z/X to rotate, Space to drop, C to hold.
// Usage: const game = main(document.querySelector('canvas'));
// Headless: const engine = main(); engine.tick(16);

Новая эра
качественного ПО.

СМЕЛЫЕ
ИДЕИ
РЕАЛЬНЫЙ
РЕЗУЛЬТАТ

Наши технологии.

AVALANCHE, LLC — компания по разработке программного обеспечения в Грузии. Мы создаём мобильные приложения, веб-продукты и инфраструктуру для них.