#!/usr/bin/env lyng /* * Lyng Console Tetris (interactive sample) * * Controls: * - Left/Right arrows or A/D: move * - Up arrow or W: rotate * - Down arrow or S: soft drop * - Space: hard drop * - P or Escape: pause * - Q: quit */ import lyng.io.console import lyng.io.fs val MIN_COLS = 56 val MIN_ROWS = 24 val PANEL_WIDTH = 24 val BOARD_MARGIN_ROWS = 5 val BOARD_MIN_W = 10 val BOARD_MAX_W = 16 val BOARD_MIN_H = 16 val BOARD_MAX_H = 28 val LEVEL_LINES_STEP = 10 val DROP_FRAMES_BASE = 15 val DROP_FRAMES_MIN = 3 val FRAME_DELAY_MS = 35 val RESIZE_WAIT_MS = 250 val ROTATION_KICKS = [0, -1, 1, -2, 2] val ANSI_ESC = "\u001b[" val ANSI_RESET = ANSI_ESC + "0m" val ERROR_LOG_PATH = "/tmp/lyng_tetris_errors.log" val UNICODE_BLOCK = "██" val UNICODE_TOP_LEFT = "┌" val UNICODE_TOP_RIGHT = "┐" val UNICODE_BOTTOM_LEFT = "└" val UNICODE_BOTTOM_RIGHT = "┘" val UNICODE_HORIZONTAL = "──" val UNICODE_VERTICAL = "│" val UNICODE_DOT = "· " type Cell = List type Rotation = List type Rotations = List type Row = List type Board = List class Piece(val name: String, val rotations: Rotations) {} class RotateResult(val ok: Bool, val rot: Int, val px: Int) {} class GameState( pieceId0: Int, nextId0: Int, next2Id0: Int, px0: Int, py0: Int, ) { var pieceId = pieceId0 var nextId = nextId0 var next2Id = next2Id0 var rot = 0 var px = px0 var py = py0 var score = 0 var totalLines = 0 var level = 1 var running = true var gameOver = false var paused = false } class LoopFrame(val resized: Bool, val originRow: Int, val originCol: Int) {} fun clearAndHome() { Console.clear() Console.home() } fun logError(message: String, err: Object?): Void { try { val details = if (err == null) "" else ": " + err Path(ERROR_LOG_PATH).appendUtf8(message + details + "\n") } catch (_: Object) { // Never let logging errors affect gameplay. } } fun repeatText(s: String, n: Int): String { var out: String = "" for (i in 0..(a: T, b: T): T = if (a > b) a else b fun emptyRow(width: Int): Row { val r: Row = [] for (x in 0.. "36" // I 2 -> "33" // O 3 -> "35" // T 4 -> "32" // S 5 -> "31" // Z 6 -> "34" // J 7 -> "93" // L else -> "37" } colorize(UNICODE_BLOCK, sgr, useColor) } fun emptyCellText(useColor: Bool): String { colorize(UNICODE_DOT, "90", useColor) } fun canPlace(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): Bool { if (pieceId < 1 || pieceId > 7) return false val piece: Piece = PIECES[pieceId - 1] if (rot < 0 || rot >= piece.rotations.size) return false val cells = piece.rotations[rot] for (cell in cells) { val x = px + cell[0] val y = py + cell[1] if (x < 0 || x >= boardW) return false if (y >= boardH) return false if (y >= 0) { val row = board[y] if (row[x] != 0) return false } } true } fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void { val piece: Piece = PIECES[pieceId - 1] val cells = piece.rotations[rot] for (cell in cells) { val x = px + cell[0] val y = py + cell[1] if (y >= 0) { val row = board[y] row[x] = pieceId } } } fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int { val b = board var y = boardH - 1 var cleared = 0 while (y >= 0) { val row = b[y] var full = true for (x in 0.. { val out: List = [] if (pieceId <= 0) { for (i in 0..<4) { out.add(" ") } return out } val piece: Piece = PIECES[pieceId - 1] val cells = piece.rotations[0] for (y in 0..<4) { var line = "" for (x in 0..<4) { var filled = false for (cell in cells) { if (cell[0] == x && cell[1] == y) { filled = true break } } line += if (filled) blockText(pieceId, useColor) else " " } out.add(line) } out } fun render( state: GameState, board: Board, boardW: Int, boardH: Int, prevFrameLines: List, originRow: Int, originCol: Int, useColor: Bool, ): List { val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, boardW) + UNICODE_BOTTOM_RIGHT val panel: List = [] val nextPiece: Piece = PIECES[state.nextId - 1] val next2Piece: Piece = PIECES[state.next2Id - 1] val nextName = nextPiece.name val next2Name = next2Piece.name val preview = nextPreviewLines(state.nextId, useColor) panel.add("Lyng Tetris") panel.add("") panel.add("Score: " + state.score) panel.add("Lines: " + state.totalLines) panel.add("Level: " + state.level) panel.add("") panel.add("Next: " + nextName) panel.add("") for (pl in preview) panel.add(pl) panel.add("After: " + next2Name) panel.add("") panel.add("Keys:") panel.add("A/D or arrows") panel.add("W/Up: rotate") panel.add("S/Down: drop") panel.add("Space: hard drop") panel.add("P/Esc: pause") panel.add("Q: quit") val frameLines: List = [] for (y in 0.. 0) a else b line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor) } line += UNICODE_VERTICAL if (y < panel.size) { line += " " + panel[y] } frameLines.add(line) } frameLines.add(bottomBorder) for (i in 0.. 0) width else 0 if (maxLen <= 0) return "" if (line.size >= maxLen) return line[.. = [] lines.add("PAUSED") lines.add("") lines.add("Any key: continue game") lines.add("Esc: exit game") lines.add("") lines.add("Move: A/D or arrows") lines.add("Rotate: W or Up") lines.add("Drop: S/Down, Space hard drop") var innerWidth = 0 for (line in lines) { if (line.size > innerWidth) innerWidth = line.size } innerWidth += 4 val maxInner = max(12, contentWidth - 2) if (innerWidth > maxInner) innerWidth = maxInner if (innerWidth % 2 != 0) innerWidth-- val boxWidth = innerWidth + 2 val boxHeight = lines.size + 2 val left = originCol + max(0, (contentWidth - boxWidth) / 2) val top = originRow + max(0, (contentHeight - boxHeight) / 2) val topBorder = UNICODE_TOP_LEFT + repeatText(UNICODE_HORIZONTAL, innerWidth / 2) + UNICODE_TOP_RIGHT val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, innerWidth / 2) + UNICODE_BOTTOM_RIGHT Console.moveTo(top, left) Console.write(topBorder) for (i in 0..= minCols && rows >= minRows) return g clearAndHome() val lines: List = [] lines.add("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows)) lines.add("Current: %sx%s"(cols, rows)) lines.add("Resize the console window to continue.") val visibleLines = if (rows < lines.size) rows else lines.size if (visibleLines > 0) { val startRow = max(1, ((rows - visibleLines) / 2) + 1) for (i in 0.. 100 * level 2 -> 300 * level 3 -> 500 * level 4 -> 800 * level else -> 0 } } // Classic 7 tetrominoes, minimal rotations per piece. fun cell(x: Int, y: Int): Cell { [x, y] } fun rot(a: Cell, b: Cell, c: Cell, d: Cell): Rotation { val r: Rotation = [] r.add(a) r.add(b) r.add(c) r.add(d) r } val PIECES: List = [] val iRots: Rotations = [] iRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(3,1))) iRots.add(rot(cell(2,0), cell(2,1), cell(2,2), cell(2,3))) PIECES.add(Piece("I", iRots)) val oRots: Rotations = [] oRots.add(rot(cell(1,0), cell(2,0), cell(1,1), cell(2,1))) PIECES.add(Piece("O", oRots)) val tRots: Rotations = [] tRots.add(rot(cell(1,0), cell(0,1), cell(1,1), cell(2,1))) tRots.add(rot(cell(1,0), cell(1,1), cell(2,1), cell(1,2))) tRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(1,2))) tRots.add(rot(cell(1,0), cell(0,1), cell(1,1), cell(1,2))) PIECES.add(Piece("T", tRots)) val sRots: Rotations = [] sRots.add(rot(cell(1,0), cell(2,0), cell(0,1), cell(1,1))) sRots.add(rot(cell(1,0), cell(1,1), cell(2,1), cell(2,2))) PIECES.add(Piece("S", sRots)) val zRots: Rotations = [] zRots.add(rot(cell(0,0), cell(1,0), cell(1,1), cell(2,1))) zRots.add(rot(cell(2,0), cell(1,1), cell(2,1), cell(1,2))) PIECES.add(Piece("Z", zRots)) val jRots: Rotations = [] jRots.add(rot(cell(0,0), cell(0,1), cell(1,1), cell(2,1))) jRots.add(rot(cell(1,0), cell(2,0), cell(1,1), cell(1,2))) jRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(2,2))) jRots.add(rot(cell(1,0), cell(1,1), cell(0,2), cell(1,2))) PIECES.add(Piece("J", jRots)) val lRots: Rotations = [] lRots.add(rot(cell(2,0), cell(0,1), cell(1,1), cell(2,1))) lRots.add(rot(cell(1,0), cell(1,1), cell(1,2), cell(2,2))) lRots.add(rot(cell(0,1), cell(1,1), cell(2,1), cell(0,2))) lRots.add(rot(cell(0,0), cell(1,0), cell(1,1), cell(1,2))) PIECES.add(Piece("L", lRots)) if (!Console.isSupported()) { println("Console API is not supported in this runtime.") void } else if (!Console.isTty()) { println("This sample needs an interactive terminal (TTY).") void } else { waitForMinimumSize(MIN_COLS, MIN_ROWS) val g0 = Console.geometry() val cols = g0?.columns ?: MIN_COLS val rows = g0?.rows ?: MIN_ROWS val boardW = clamp((cols - PANEL_WIDTH) / 2, BOARD_MIN_W..BOARD_MAX_W) val boardH = clamp(rows - BOARD_MARGIN_ROWS, BOARD_MIN_H..BOARD_MAX_H) val board: Board = createBoard(boardW, boardH) fun nextPieceId() { Random.next(1..7) } val state: GameState = GameState( nextPieceId(), nextPieceId(), nextPieceId(), (boardW / 2) - 2, -1, ) var prevFrameLines: List = [] val gameMutex = Mutex() var hasResizeEvent = false val rawModeEnabled = Console.setRawMode(true) if (!rawModeEnabled) { println("Raw keyboard mode is not available in this terminal/runtime.") println("Use jlyng in an interactive terminal with raw input support.") void } else { val useColor = Console.ansiLevel() != ConsoleAnsiLevel.NONE Console.enterAltScreen() Console.setCursorVisible(false) clearAndHome() fun resetActivePiece(s: GameState): Void { s.pieceId = nextPieceId() s.rot = 0 s.px = (boardW / 2) - 2 s.py = -1 } fun applyKeyInput(s: GameState, key: String): Void { try { if (key == "__CTRL_C__") { s.running = false } else if (s.paused) { if (key == "Escape") { s.running = false } else { s.paused = false prevFrameLines = [] } } else if (key == "p" || key == "P" || key == "Escape") { s.paused = true prevFrameLines = [] } else if (key == "q" || key == "Q") { s.running = false } else if (key == "ArrowLeft" || key == "a" || key == "A") { if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px - 1, s.py) == true) s.px-- } else if (key == "ArrowRight" || key == "d" || key == "D") { if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px + 1, s.py) == true) s.px++ } else if (key == "ArrowUp" || key == "w" || key == "W") { val rr: RotateResult = tryRotateCw(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py) if (rr.ok) { s.rot = rr.rot s.px = rr.px } } else if (key == "ArrowDown" || key == "s" || key == "S") { if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1) == true) { s.py++ s.score++ } } else if (key == " ") { while (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1) == true) { s.py++ s.score += 2 } } } catch (inputErr: Object) { logError("applyKeyInput recovered after error", inputErr) resetActivePiece(s) } } var inputRunning = true launch { while (inputRunning) { try { for (ev in Console.events()) { if (!inputRunning) break // Isolate per-event failures so one bad event does not unwind the stream. try { if (ev is ConsoleKeyEvent) { val ke = ev as ConsoleKeyEvent if (ke.type == ConsoleEventType.KEY_DOWN) { var key = "" var ctrl = false try { key = ke.key } catch (keyErr: Object) { logError("Input key read error", keyErr) } try { ctrl = ke.ctrl == true } catch (_: Object) { ctrl = false } if (key == "") { logError("Dropped key event with empty/null key", null) continue } gameMutex.withLock { val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key applyKeyInput(state, mapped) } } } else if (ev is ConsoleResizeEvent) { gameMutex.withLock { hasResizeEvent = true prevFrameLines = [] } } } catch (eventErr: Object) { // Keep the input stream alive; report for diagnostics. logError("Input event error", eventErr) } } } catch (err: Object) { // Recover stream-level failures by recreating event stream in next loop turn. if (!inputRunning) break logError("Input stream recovered after error", err) Console.setRawMode(true) delay(50) } } } fun pollLoopFrame(): LoopFrame? { val g = Console.geometry() val c = g?.columns ?: 0 val r = g?.rows ?: 0 if (c < MIN_COLS || r < MIN_ROWS) { waitForMinimumSize(MIN_COLS, MIN_ROWS) clearAndHome() prevFrameLines = [] return null } var resized = false gameMutex.withLock { resized = hasResizeEvent hasResizeEvent = false } val contentCols = boardW * 2 + 2 + 3 + PANEL_WIDTH val contentRows = boardH + 1 val requiredCols = max(MIN_COLS, contentCols) val requiredRows = max(MIN_ROWS, contentRows) if (c < requiredCols || r < requiredRows) { waitForMinimumSize(requiredCols, requiredRows) clearAndHome() prevFrameLines = [] return null } val originCol = max(1, ((c - contentCols) / 2) + 1) val originRow = max(1, ((r - contentRows) / 2) + 1) LoopFrame(resized, originRow, originCol) } fun advanceGravity(s: GameState, frame: Int): Int { s.level = 1 + (s.totalLines / LEVEL_LINES_STEP) val dropEvery = max(DROP_FRAMES_MIN, DROP_FRAMES_BASE - s.level) var nextFrame = frame + 1 if (nextFrame < dropEvery) return nextFrame nextFrame = 0 if (canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py + 1) == true) { s.py++ return nextFrame } lockPiece(board, s.pieceId, s.rot, s.px, s.py) val cleared = clearCompletedLines(board, boardW, boardH) if (cleared > 0) { s.totalLines += cleared s.score += scoreForLines(cleared, s.level) } s.pieceId = s.nextId s.nextId = s.next2Id s.next2Id = nextPieceId() s.rot = 0 s.px = (boardW / 2) - 2 s.py = -1 if (!canPlace(board, boardW, boardH, s.pieceId, s.rot, s.px, s.py)) { s.gameOver = true } nextFrame } try { if (!canPlace(board, boardW, boardH, state.pieceId, state.rot, state.px, state.py)) { state.gameOver = true } var frame = 0 var shouldStop = false var prevOriginRow = -1 var prevOriginCol = -1 while (!shouldStop) { val frameData = pollLoopFrame() if (frameData == null) { frame = 0 continue } gameMutex.withLock { if (!state.running || state.gameOver) { shouldStop = true } else { val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol if (frameData.resized || movedOrigin) { clearAndHome() prevFrameLines = [] } prevOriginRow = frameData.originRow prevOriginCol = frameData.originCol if (!state.paused) { frame = advanceGravity(state, frame) } prevFrameLines = render( state, board, boardW, boardH, prevFrameLines, frameData.originRow, frameData.originCol, useColor ) if (state.paused) { renderPauseOverlay(frameData.originRow, frameData.originCol, boardW, boardH) } } } Console.flush() delay(FRAME_DELAY_MS) } } finally { inputRunning = false Console.setRawMode(false) Console.setCursorVisible(true) Console.leaveAltScreen() Console.flush() } if (state.gameOver) { println("Game over.") } else { println("Bye.") } println("Score: %s"(state.score)) println("Lines: %s"(state.totalLines)) println("Level: %s"(state.level)) } }