feat(game): реализация ядра игровой логики ColorLine98

- Добавлен класс Game для управления состоянием игры
- Реализованы: генерация шаров, превью следующих 3 шаров
- Подсчёт очков по таблице из GDD (5→10, 6→12, 7→18, 8→28, 9+→формула)
- Комбо-множитель x1.5 за каждую дополнительную линию
- Условие окончания игры (поле заполнено)
- Исправлены type imports для verbatimModuleSyntax

Refs: [GAM-8](/GAM/issues/GAM-8)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
aevgarik
2026-03-22 21:48:10 +03:00
parent 463d09bbc2
commit 7c987be2c1
4 changed files with 203 additions and 2 deletions

198
src/game/Game.ts Normal file
View File

@@ -0,0 +1,198 @@
/**
* @author aevgarik@gmail.com
* @date 2026-03-22
*
* Главный класс игры ColorLine98
*/
import type { Board, BallColor } from './Board'
import {
createEmptyBoard,
getRandomColor,
getEmptyCells,
INITIAL_BALLS,
BALLS_PER_TURN,
} from './Board'
import { findPath } from './PathFinder'
import { checkLines } from './LineChecker'
import type { LineInfo } from './LineChecker'
const SCORE_TABLE: Record<number, number> = {
5: 10,
6: 12,
7: 18,
8: 28,
}
function getLineScore(length: number): number {
if (SCORE_TABLE[length] !== undefined) {
return SCORE_TABLE[length]
}
if (length >= 9) {
return (length - 8) * 14 + 42
}
return 0
}
export interface GameState {
board: Board
score: number
nextBalls: BallColor[]
selectedCell: { row: number; col: number } | null
gameOver: boolean
}
export type GameListener = (state: GameState) => void
export class Game {
private state: GameState
private listeners: GameListener[] = []
constructor() {
this.state = {
board: createEmptyBoard(),
score: 0,
nextBalls: [],
selectedCell: null,
gameOver: false,
}
}
getState(): GameState {
return { ...this.state }
}
subscribe(listener: GameListener): () => void {
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
private notify(): void {
const state = this.getState()
this.listeners.forEach((l) => l(state))
}
startNewGame(): void {
this.state = {
board: createEmptyBoard(),
score: 0,
nextBalls: [],
selectedCell: null,
gameOver: false,
}
this.placeRandomBalls(INITIAL_BALLS)
this.generateNextBalls()
this.notify()
}
private placeRandomBalls(count: number): boolean {
const emptyCells = getEmptyCells(this.state.board)
if (emptyCells.length < count) {
return false
}
for (let i = 0; i < count; i++) {
const randomIndex = Math.floor(Math.random() * emptyCells.length)
const { row, col } = emptyCells.splice(randomIndex, 1)[0]
this.state.board[row][col].ball = getRandomColor()
}
return true
}
private generateNextBalls(): void {
this.state.nextBalls = Array.from({ length: BALLS_PER_TURN }, () => getRandomColor())
}
selectCell(row: number, col: number): boolean {
if (this.state.gameOver) return false
const cell = this.state.board[row][col]
if (cell.ball !== null) {
this.state.selectedCell = { row, col }
this.notify()
return true
}
if (this.state.selectedCell === null) {
return false
}
const from = this.state.selectedCell
const path = findPath(this.state.board, from, { row, col })
if (path === null) {
return false
}
const ball = this.state.board[from.row][from.col].ball
this.state.board[from.row][from.col].ball = null
this.state.board[row][col].ball = ball
this.state.selectedCell = null
const lines = checkLines(this.state.board, row, col)
if (lines.length > 0) {
this.processLines(lines)
} else {
if (!this.placeNextBalls()) {
this.state.gameOver = true
}
}
this.notify()
return true
}
private processLines(lines: LineInfo[]): void {
let totalScore = 0
let multiplier = 1
const sortedLines = [...lines].sort((a, b) => b.cells.length - a.cells.length)
for (const line of sortedLines) {
const baseScore = getLineScore(line.cells.length)
totalScore += Math.floor(baseScore * multiplier)
multiplier *= 1.5
for (const { row, col } of line.cells) {
this.state.board[row][col].ball = null
}
}
this.state.score += totalScore
}
private placeNextBalls(): boolean {
const emptyCells = getEmptyCells(this.state.board)
if (emptyCells.length < BALLS_PER_TURN) {
const ballsToPlace = Math.min(emptyCells.length, this.state.nextBalls.length)
if (ballsToPlace === 0) {
return false
}
for (let i = 0; i < ballsToPlace; i++) {
const randomIndex = Math.floor(Math.random() * emptyCells.length)
const { row, col } = emptyCells.splice(randomIndex, 1)[0]
this.state.board[row][col].ball = this.state.nextBalls[i]
}
this.generateNextBalls()
return emptyCells.length > 0
}
for (let i = 0; i < BALLS_PER_TURN; i++) {
const randomIndex = Math.floor(Math.random() * emptyCells.length)
const { row, col } = emptyCells.splice(randomIndex, 1)[0]
this.state.board[row][col].ball = this.state.nextBalls[i]
}
this.generateNextBalls()
return true
}
}

View File

@@ -5,7 +5,8 @@
* Проверка линий для ColorLine98
*/
import { BOARD_SIZE, BallColor, MIN_LINE_LENGTH } from './Board'
import { BOARD_SIZE, MIN_LINE_LENGTH } from './Board'
import type { BallColor } from './Board'
export interface LineInfo {
cells: { row: number; col: number }[]

View File

@@ -5,7 +5,8 @@
* Поиск пути (BFS) для ColorLine98
*/
import { BOARD_SIZE, Board } from './Board'
import { BOARD_SIZE } from './Board'
import type { Board } from './Board'
const DIRECTIONS = [
[-1, 0],

View File

@@ -8,3 +8,4 @@
export * from './Board'
export * from './PathFinder'
export * from './LineChecker'
export * from './Game'