/// TETRIG 코어 엔진 (Dart 이식) — Phase 1. /// 순수 로직만: Flutter/렌더/타이머 의존 없음. UI 위젯이 타이밍을 구동한다. /// /// 좌표 규약 (명세서 §4.1): /// - 보드 (x, y): x 오른쪽 증가, y 위쪽 증가. y=0 이 맨 아래. /// - 킥 오프셋 (dx, dy): dy 양수 = 위쪽. 그대로 py += dy 로 적용. /// - 회전 상태: 0=스폰, 1=CW, 2=180, 3=CCW. /// /// (웹 레퍼런스 src/engine.js, Kotlin 이식판과 동일 알고리즘·데이터) library; /// 튜닝 상수 (조정 대상은 여기 한 곳). class Config { static const int boardW = 10; static const int boardH = 40; // 내부 높이 (하단 20행만 표시) static const int visibleH = 20; static const int nextCount = 5; static const int lockDelayMs = 500; static const int maxLockResets = 15; static const int defaultGravityMs = 800; // Phase 1 기본 (SPRINT) static const int dasMs = 133; static const int arrMs = 10; static const int sdf = 20; } const List pieceTypes = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']; /// 피스 색 (명세서 §13 v2 확정). 'G' = 가비지. const Map pieceColors = { 'I': '#3EC1B6', 'O': '#F5C531', 'T': '#A76BF2', 'S': '#7FCC4C', 'Z': '#F25C4C', 'J': '#4C7DF2', 'L': '#F2913D', 'G': '#46464F', }; // ── 피스 스폰 셀 (박스-로컬, y 위쪽 증가) + 박스 크기 ── class _Spawn { final int box; final List> cells; const _Spawn(this.box, this.cells); } const Map _spawn = { 'I': _Spawn(4, [[0, 2], [1, 2], [2, 2], [3, 2]]), 'O': _Spawn(2, [[0, 0], [1, 0], [0, 1], [1, 1]]), 'T': _Spawn(3, [[0, 1], [1, 1], [2, 1], [1, 2]]), 'S': _Spawn(3, [[0, 1], [1, 1], [1, 2], [2, 2]]), 'Z': _Spawn(3, [[0, 2], [1, 2], [1, 1], [2, 1]]), 'J': _Spawn(3, [[0, 2], [0, 1], [1, 1], [2, 1]]), 'L': _Spawn(3, [[2, 2], [0, 1], [1, 1], [2, 1]]), }; /// 스폰 시 박스 원점의 보드 위치 (px, py). const Map> _spawnPos = { 'I': [3, 19], 'O': [4, 21], 'T': [3, 20], 'S': [3, 20], 'Z': [3, 20], 'J': [3, 20], 'L': [3, 20], }; /// CW 회전: 박스 b 에서 (x,y) -> (y, (b-1)-x). List> _rotateCW(List> cells, int b) => cells.map((c) => [c[1], (b - 1) - c[0]]).toList(); List>> _buildStates(String type) { final sp = _spawn[type]!; if (type == 'O') return [sp.cells, sp.cells, sp.cells, sp.cells]; final s0 = sp.cells; final s1 = _rotateCW(s0, sp.box); final s2 = _rotateCW(s1, sp.box); final s3 = _rotateCW(s2, sp.box); return [s0, s1, s2, s3]; } /// STATES[type][state] = 셀 목록. final Map>>> states = { for (final t in pieceTypes) t: _buildStates(t), }; // ── 킥테이블 (명세서 §4.1) ── const Map>> _kicksJlstz = { '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]], // 180° (SRS+) '0>2': [[0, 0], [0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0]], '2>0': [[0, 0], [0, -1], [-1, -1], [1, -1], [-1, 0], [1, 0]], '1>3': [[0, 0], [1, 0], [1, 2], [1, 1], [0, 2], [0, 1]], '3>1': [[0, 0], [-1, 0], [-1, 2], [-1, 1], [0, 2], [0, 1]], }; const Map>> _kicksI = { '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]], // I 180°는 (0,0)만 '0>2': [[0, 0]], '2>0': [[0, 0]], '1>3': [[0, 0]], '3>1': [[0, 0]], }; const List> _origin = [[0, 0]]; List> kickTable(String type, int from, int to) { final key = '$from>$to'; if (type == 'O') return _origin; if (type == 'I') return _kicksI[key] ?? _origin; return _kicksJlstz[key] ?? _origin; } /// 시드 가능 난수 (mulberry32). double Function() makeRng(int seed) { int a = seed & 0xFFFFFFFF; return () { a = (a + 0x6D2B79F5) & 0xFFFFFFFF; final x = (a ^ (a >>> 15)) & 0xFFFFFFFF; int t = (x * ((1 | a) & 0xFFFFFFFF)) & 0xFFFFFFFF; final y = (t ^ (t >>> 7)) & 0xFFFFFFFF; final m = (y * ((61 | t) & 0xFFFFFFFF)) & 0xFFFFFFFF; t = ((t + m) & 0xFFFFFFFF) ^ t; t &= 0xFFFFFFFF; return ((t ^ (t >>> 14)) & 0xFFFFFFFF) / 4294967296.0; }; } /// 7-bag. class SevenBag { final double Function() rng; final List _q = []; SevenBag([double Function()? rng]) : rng = rng ?? (() => 0.0) { _refill(); _refill(); } void _refill() { final bag = List.from(pieceTypes); for (int i = bag.length - 1; i > 0; i--) { final j = (rng() * (i + 1)).floor(); final tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp; } _q.addAll(bag); } String next() { if (_q.length <= Config.nextCount + 1) _refill(); return _q.removeAt(0); } List peek([int n = Config.nextCount]) { while (_q.length < n) { _refill(); } return _q.sublist(0, n); } } // ── 보드 유틸 ── List> createBoard([int w = Config.boardW, int h = Config.boardH]) => List.generate(h, (_) => List.filled(w, null)); List> pieceCells(String type, int state, int px, int py) => states[type]![state].map((c) => [px + c[0], py + c[1]]).toList(); /// x 범위 밖 / 바닥 아래(y<0) / 점유 = 충돌. y >= H (버퍼 위)는 허용. bool collides(List> board, List> cells) { final w = board[0].length, h = board.length; for (final c in cells) { final x = c[0], y = c[1]; if (x < 0 || x >= w || y < 0) return true; if (y < h && board[y][x] != null) return true; } return false; } /// 가득 찬 행 제거 후 위에서 빈 행 보충. 제거된 행 수 반환. int clearLines(List> board) { final w = board[0].length, h = board.length; final rows = []; for (int y = 0; y < h; y++) { if (board[y].every((c) => c != null)) rows.add(y); } if (rows.isEmpty) return 0; final rowSet = rows.toSet(); final kept = >[ for (int y = 0; y < h; y++) if (!rowSet.contains(y)) board[y] ]; while (kept.length < h) { kept.add(List.filled(w, null)); } for (int y = 0; y < h; y++) { board[y] = kept[y]; } return rows.length; } bool isBoardEmpty(List> board) => board.every((row) => row.every((c) => c == null)); /// T-spin 판정 (명세서 §4.2). 'none' | 'mini' | 'tspin'. String detectTSpin(List> board, String type, int state, int px, int py, bool lastMoveRotation) { if (type != 'T' || !lastMoveRotation) return 'none'; final w = board[0].length, h = board.length; final cx = px + 1, cy = py + 1; bool filled(int dx, int dy) { final x = cx + dx, y = cy + dy; if (x < 0 || x >= w || y < 0) return true; // 벽/바닥 = 채움 if (y >= h) return false; // 버퍼 위 = 빔 return board[y][x] != null; } final tl = filled(-1, 1), tr = filled(1, 1), bl = filled(-1, -1), br = filled(1, -1); final count = [tl, tr, bl, br].where((v) => v).length; if (count < 3) return 'none'; late List front; switch (state) { case 0: front = [tl, tr]; break; case 1: front = [tr, br]; break; case 2: front = [bl, br]; break; default: front = [tl, bl]; } return (front[0] && front[1]) ? 'tspin' : 'mini'; } /// B2B 대상("어려운" 클리어): Quad 또는 라인 동반 T-spin 계열. bool isDifficult(int lines, String tspin) => lines == 4 || (tspin != 'none' && lines >= 1); class LockResult { final String type; final int lines; final String tspin; final bool allClear; final int combo; final int b2b; final bool difficult; const LockResult(this.type, this.lines, this.tspin, this.allClear, this.combo, this.b2b, this.difficult); } class Active { String type; int state; int x; int y; Active(this.type, this.state, this.x, this.y); } /// 게임 상태 (UI 가 타이밍을 구동). class TetrigGame { List> board = createBoard(); final SevenBag _bag; String? hold; bool holdUsed = false; bool over = false; bool lastMoveRotation = false; int lockResets = 0; int combo = -1; int b2b = -1; LockResult? lastClear; late Active active; TetrigGame({int seed = 1}) : _bag = SevenBag(makeRng(seed)) { spawn(); } List> cells([Active? a]) { a ??= active; return pieceCells(a.type, a.state, a.x, a.y); } bool spawn([String? type]) { type ??= _bag.next(); final pos = _spawnPos[type]!; active = Active(type, 0, pos[0], pos[1]); lastMoveRotation = false; lockResets = 0; if (collides(board, cells())) { over = true; return false; } return true; } List peekNext([int n = Config.nextCount]) => _bag.peek(n); bool canFall() { final c = cells().map((p) => [p[0], p[1] - 1]).toList(); return !collides(board, c); } bool resetLockDelay() { if (lockResets >= Config.maxLockResets) return false; lockResets++; return true; } bool move(int dx) { if (over) return false; final c = pieceCells(active.type, active.state, active.x + dx, active.y); if (collides(board, c)) return false; active.x += dx; lastMoveRotation = false; return true; } bool softDrop() { if (over) return false; final c = pieceCells(active.type, active.state, active.x, active.y - 1); if (collides(board, c)) return false; active.y -= 1; lastMoveRotation = false; return true; } /// dir: 'cw' | 'ccw' | '180'. bool rotate(String dir) { if (over) return false; final a = active; final to = dir == 'cw' ? (a.state + 1) % 4 : dir == 'ccw' ? (a.state + 3) % 4 : (a.state + 2) % 4; for (final k in kickTable(a.type, a.state, to)) { final nx = a.x + k[0], ny = a.y + k[1]; if (!collides(board, pieceCells(a.type, to, nx, ny))) { a.state = to; a.x = nx; a.y = ny; lastMoveRotation = true; return true; } } return false; } List> ghostCells() { final a = active; int dy = 0; while (!collides(board, pieceCells(a.type, a.state, a.x, a.y - dy - 1))) { dy++; } return pieceCells(a.type, a.state, a.x, a.y - dy); } LockResult? hardDrop() { if (over) return null; final a = active; int d = 0; while (!collides(board, pieceCells(a.type, a.state, a.x, a.y - d - 1))) { d++; } a.y -= d; return lock(); } bool holdSwap() { if (over || holdUsed) return false; final cur = active.type; if (hold == null) { hold = cur; spawn(); } else { final swap = hold!; hold = cur; spawn(swap); } holdUsed = true; return true; } LockResult lock() { final a = active; final tspin = detectTSpin(board, a.type, a.state, a.x, a.y, lastMoveRotation); for (final c in cells()) { if (c[1] >= 0 && c[1] < board.length) board[c[1]][c[0]] = a.type; } final cleared = clearLines(board); final allClear = cleared > 0 && isBoardEmpty(board); combo = cleared > 0 ? combo + 1 : -1; final diff = isDifficult(cleared, tspin); if (cleared > 0) b2b = diff ? b2b + 1 : -1; final result = LockResult(a.type, cleared, tspin, allClear, combo, b2b, diff); lastClear = result; holdUsed = false; spawn(); return result; } }