lyng/examples/tetris_console.lyng

815 lines
25 KiB
Plaintext
Executable File

#!/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
Tsted to score:
sergeych@sergeych-XPS-17-9720:~$ ~/dev/lyng/examples/tetris_console.lyng
Bye.
Score: 435480
Lines: 271
Level: 28
Ssergeych@sergeych-XPS-17-9720:~$
*/
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 MAX_PENDING_INPUTS = 64
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<Int>
type Rotation = List<Cell>
type Rotations = List<Rotation>
type Row = List<Int>
type Board = List<Row>
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 {
var details = ""
if (err != null) {
try {
details = ": " + err
} catch (_: Object) {
details = ": <error-format-failed>"
}
}
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..<n) {
out += s
}
out
}
fun max<T>(a: T, b: T): T = if (a > b) a else b
fun emptyRow(width: Int): Row {
val r: Row = []
for (x in 0..<width) {
r.add(0)
}
r
}
fun createBoard(width: Int, height: Int): Board {
val b: Board = []
for (y in 0..<height) {
b.add(emptyRow(width))
}
b
}
fun colorize(text: String, sgr: String, useColor: Bool): String {
if (!useColor) return text
ANSI_ESC + sgr + "m" + text + ANSI_RESET
}
fun blockText(pieceId: Int, useColor: Bool): String {
if (pieceId <= 0) return " "
val sgr = when (pieceId) {
1 -> "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 {
try {
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) {
if (y >= board.size) return false
val row = board[y]
if (row == null) return false
if (row[x] != 0) return false
}
}
true
} catch (_: Object) {
false
}
}
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 && y < board.size) {
val row = board[y]
if (row != null) {
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) {
if (y >= b.size) {
y--
continue
}
val row = b[y]
if (row == null) {
b.removeAt(y)
b.insertAt(0, emptyRow(boardW))
cleared++
continue
}
var full = true
for (x in 0..<boardW) {
if (row[x] == 0) {
full = false
break
}
}
if (full) {
b.removeAt(y)
b.insertAt(0, emptyRow(boardW))
cleared++
} else {
y--
}
}
cleared
}
fun activeCellId(pieceId: Int, rot: Int, px: Int, py: Int, x: Int, y: Int): Int {
val piece: Piece = PIECES[pieceId - 1]
val cells = piece.rotations[rot]
for (cell in cells) {
val ax = px + cell[0]
val ay = py + cell[1]
if (ax == x && ay == y) return pieceId
}
0
}
fun tryRotateCw(board: Board, boardW: Int, boardH: Int, pieceId: Int, rot: Int, px: Int, py: Int): RotateResult {
val piece: Piece = PIECES[pieceId - 1]
val rotations = piece.rotations.size
val nr = (rot + 1) % rotations
for (kx in ROTATION_KICKS) {
val nx = px + kx
if (canPlace(board, boardW, boardH, pieceId, nr, nx, py) == true) {
return RotateResult(true, nr, nx)
}
}
RotateResult(false, rot, px)
}
fun nextPreviewLines(pieceId: Int, useColor: Bool): List<String> {
val out: List<String> = []
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<String>,
originRow: Int,
originCol: Int,
useColor: Bool,
): List<String> {
val bottomBorder = UNICODE_BOTTOM_LEFT + repeatText(UNICODE_HORIZONTAL, boardW) + UNICODE_BOTTOM_RIGHT
val panel: List<String> = []
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<String> = []
for (y in 0..<boardH) {
var line = UNICODE_VERTICAL
for (x in 0..<boardW) {
val a = activeCellId(state.pieceId, state.rot, state.px, state.py, x, y)
val row = if (y < board.size) board[y] else null
val b = if (row == null) 0 else row[x]
val id = if (a > 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..<frameLines.size) {
val line = frameLines[i]
val old = if (i < prevFrameLines.size) prevFrameLines[i] else null
if (old != line) {
Console.moveTo(originRow + i, originCol)
Console.clearLine()
Console.write(line)
}
}
frameLines
}
fun fitLine(line: String, width: Int): String {
val maxLen = if (width > 0) width else 0
if (maxLen <= 0) return ""
if (line.size >= maxLen) return line[..<maxLen]
line + repeatText(" ", maxLen - line.size)
}
fun renderPauseOverlay(
originRow: Int,
originCol: Int,
boardW: Int,
boardH: Int,
): Void {
val contentWidth = boardW * 2 + 2 + 3 + PANEL_WIDTH
val contentHeight = boardH + 1
val lines: List<String> = []
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..<lines.size) {
Console.moveTo(top + 1 + i, left)
Console.write(UNICODE_VERTICAL + fitLine(" " + lines[i], innerWidth) + UNICODE_VERTICAL)
}
Console.moveTo(top + boxHeight - 1, left)
Console.write(bottomBorder)
}
fun waitForMinimumSize(minCols: Int, minRows: Int): Object {
while (true) {
val g = Console.geometry()
val cols = g?.columns ?: 0
val rows = g?.rows ?: 0
if (cols >= minCols && rows >= minRows) return g
clearAndHome()
val lines: List<String> = []
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..<visibleLines) {
val line = lines[i]
val startCol = max(1, ((cols - line.size) / 2) + 1)
Console.moveTo(startRow + i, startCol)
Console.clearLine()
Console.write(line)
}
Console.flush()
}
delay(RESIZE_WAIT_MS)
}
}
fun scoreForLines(cleared: Int, level: Int): Int {
when (cleared) {
1 -> 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<Piece> = []
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<String> = []
val gameMutex: Mutex = Mutex()
var forceRedraw = false
val pendingInputs: List<String> = []
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
forceRedraw = true
}
}
else if (key == "p" || key == "P" || key == "Escape") {
s.paused = true
forceRedraw = true
}
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
}
val mapped = if (ctrl && (key == "c" || key == "C")) "__CTRL_C__" else key
val mm: Mutex = gameMutex
mm.withLock {
if (pendingInputs.size >= MAX_PENDING_INPUTS) {
pendingInputs.removeAt(0)
}
pendingInputs.add(mapped)
}
}
}
} 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
}
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(false, 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
var prevPaused = false
while (!shouldStop) {
val frameData = pollLoopFrame()
if (frameData == null) {
frame = 0
prevPaused = false
continue
}
val mm: Mutex = gameMutex
mm.withLock {
if (pendingInputs.size > 0) {
val toApply: List<String> = []
while (pendingInputs.size > 0) {
val k = pendingInputs[0]
pendingInputs.removeAt(0)
toApply.add(k)
}
for (k in toApply) {
applyKeyInput(state, k)
if (!state.running || state.gameOver) break
}
}
}
if (!state.running || state.gameOver) {
shouldStop = true
} else {
val localForceRedraw = forceRedraw
forceRedraw = false
val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol
if (frameData.resized || movedOrigin || localForceRedraw) {
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 && (!prevPaused || frameData.resized || movedOrigin)) {
renderPauseOverlay(frameData.originRow, frameData.originCol, boardW, boardH)
}
prevPaused = state.paused
}
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))
}
}