diff --git a/examples/tetris_console.lyng b/examples/tetris_console.lyng index 22909ad..08c90a6 100755 --- a/examples/tetris_console.lyng +++ b/examples/tetris_console.lyng @@ -10,6 +10,14 @@ * - 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 @@ -28,6 +36,7 @@ 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" @@ -78,7 +87,14 @@ fun clearAndHome() { fun logError(message: String, err: Object?): Void { try { - val details = if (err == null) "" else ": " + err + var details = "" + if (err != null) { + try { + details = ": " + err + } catch (_: Object) { + details = ": " + } + } Path(ERROR_LOG_PATH).appendUtf8(message + details + "\n") } catch (_: Object) { // Never let logging errors affect gameplay. @@ -136,24 +152,30 @@ fun emptyCellText(useColor: Bool): String { } 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] + 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] + 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 (x < 0 || x >= boardW) return false + if (y >= boardH) return false - if (y >= 0) { - val row = board[y] - if (row[x] != 0) 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 } - true } fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void { @@ -164,9 +186,11 @@ fun lockPiece(board: Board, pieceId: Int, rot: Int, px: Int, py: Int): Void { val x = px + cell[0] val y = py + cell[1] - if (y >= 0) { + if (y >= 0 && y < board.size) { val row = board[y] - row[x] = pieceId + if (row != null) { + row[x] = pieceId + } } } } @@ -177,7 +201,17 @@ fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int { 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.. 0) a else b line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor) } @@ -507,8 +541,9 @@ if (!Console.isSupported()) { ) var prevFrameLines: List = [] - val gameMutex = Mutex() - var hasResizeEvent = false + val gameMutex: Mutex = Mutex() + var forceRedraw = false + val pendingInputs: List = [] val rawModeEnabled = Console.setRawMode(true) if (!rawModeEnabled) { @@ -538,12 +573,12 @@ if (!Console.isSupported()) { s.running = false } else { s.paused = false - prevFrameLines = [] + forceRedraw = true } } else if (key == "p" || key == "P" || key == "Escape") { s.paused = true - prevFrameLines = [] + forceRedraw = true } else if (key == "q" || key == "Q") { s.running = false @@ -607,16 +642,15 @@ if (!Console.isSupported()) { 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) + 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) } } - } else if (ev is ConsoleResizeEvent) { - gameMutex.withLock { - hasResizeEvent = true - prevFrameLines = [] - } } } catch (eventErr: Object) { // Keep the input stream alive; report for diagnostics. @@ -644,12 +678,6 @@ if (!Console.isSupported()) { 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) @@ -663,7 +691,7 @@ if (!Console.isSupported()) { val originCol = max(1, ((c - contentCols) / 2) + 1) val originRow = max(1, ((r - contentRows) / 2) + 1) - LoopFrame(resized, originRow, originCol) + LoopFrame(false, originRow, originCol) } fun advanceGravity(s: GameState, frame: Int): Int { @@ -709,42 +737,60 @@ if (!Console.isSupported()) { 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 } - gameMutex.withLock { - if (!state.running || state.gameOver) { - shouldStop = true - } else { - val movedOrigin = frameData.originRow != prevOriginRow || frameData.originCol != prevOriginCol - if (frameData.resized || movedOrigin) { - clearAndHome() - prevFrameLines = [] + val mm: Mutex = gameMutex + mm.withLock { + if (pendingInputs.size > 0) { + val toApply: List = [] + while (pendingInputs.size > 0) { + val k = pendingInputs[0] + pendingInputs.removeAt(0) + toApply.add(k) } - 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) + 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) } diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt index 7dfde33..64a42b0 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt @@ -37,10 +37,7 @@ import net.sergeych.lyng.obj.toObj import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.raiseIllegalOperation import net.sergeych.lyng.requireScope -import net.sergeych.lyngio.console.ConsoleEvent -import net.sergeych.lyngio.console.ConsoleEventSource -import net.sergeych.lyngio.console.LyngConsole -import net.sergeych.lyngio.console.getSystemConsole +import net.sergeych.lyngio.console.* import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException import net.sergeych.lyngio.console.security.ConsoleAccessPolicy import net.sergeych.lyngio.console.security.LyngConsoleSecured @@ -245,13 +242,27 @@ private class ObjConsoleEventIterator( private suspend fun ensureCached(): Boolean { if (closed) return false if (cached != null) return true - val event = source.nextEvent() - if (event == null) { - closeSource() - return false + while (!closed && cached == null) { + val event = try { + source.nextEvent() + } catch (e: Throwable) { + // Consumer loops must survive source/read failures: report and keep polling. + consoleFlowDebug("console-bridge: nextEvent failed; dropping failure and continuing", e) + continue + } + if (event == null) { + closeSource() + return false + } + cached = try { + event.toObjEvent() + } catch (e: Throwable) { + // Malformed/native event payload must not terminate consumer iteration. + consoleFlowDebug("console-bridge: malformed event dropped: $event", e) + null + } } - cached = event.toObjEvent() - return true + return cached != null } private suspend fun closeSource() { @@ -292,8 +303,14 @@ private class ObjConsoleEventIterator( private fun ConsoleEvent.toObjEvent(): Obj = when (this) { is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows) - is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_DOWN, key = key, codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) - is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_UP, key = key, codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) + is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_DOWN, key = sanitizedKeyOrFallback(key), codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) + is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_UP, key = sanitizedKeyOrFallback(key), codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) +} + +private fun sanitizedKeyOrFallback(key: String): String { + if (key.isNotEmpty()) return key + consoleFlowDebug("console-bridge: empty key value received; using fallback key name") + return "Unknown" } private object ConsoleEnums { diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt index 55a1aa0..13b2eb7 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/console/MordantLyngConsole.kt @@ -100,8 +100,9 @@ object MordantLyngConsole : LyngConsole { var running = true globalLaunch { - var lastWidth = t.updateSize().width - var lastHeight = t.updateSize().height + val initialSize = runCatching { t.updateSize() }.getOrNull() + var lastWidth = initialSize?.width ?: 0 + var lastHeight = initialSize?.height ?: 0 val startMark = TimeSource.Monotonic.markNow() var lastHeartbeatMark = startMark var loops = 0L @@ -113,6 +114,18 @@ object MordantLyngConsole : LyngConsole { var lastKeyMark = startMark var lastRawRecoveryMark = startMark + fun tryEmitResize(width: Int, height: Int) { + if (width < 1 || height < 1) { + consoleFlowDebug("events: ignored invalid resize width=$width height=$height") + return + } + if (width == lastWidth && height == lastHeight) return + out.trySend(ConsoleEvent.Resize(width, height)) + lastWidth = width + lastHeight = height + resizeEvents += 1 + } + consoleFlowDebug("events: collector started") try { while (currentCoroutineContext().isActive && sourceState.withLock { running }) { @@ -122,12 +135,7 @@ object MordantLyngConsole : LyngConsole { delay(150) continue } - if (currentSize.width != lastWidth || currentSize.height != lastHeight) { - out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height)) - lastWidth = currentSize.width - lastHeight = currentSize.height - resizeEvents += 1 - } + tryEmitResize(currentSize.width, currentSize.height) val raw = stateMutex.withLock { if (!rawModeRequested) { @@ -173,10 +181,8 @@ object MordantLyngConsole : LyngConsole { val ev = readResult.getOrNull() val resized = runCatching { t.updateSize() }.getOrNull() - if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) { - out.trySend(ConsoleEvent.Resize(resized.width, resized.height)) - lastWidth = resized.width - lastHeight = resized.height + if (resized != null) { + tryEmitResize(resized.width, resized.height) } when (ev) { diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt index 827ec49..a6f0f8b 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt @@ -215,10 +215,29 @@ object JvmLyngConsole : LyngConsole { var reader = activeTerm.reader() var keyThread: Thread? = null var heartbeatThread: Thread? = null + val resizeEmitMutex = Any() + var lastResizeCols = Int.MIN_VALUE + var lastResizeRows = Int.MIN_VALUE fun emitResize() { val size = runCatching { activeTerm.size }.getOrNull() ?: return - out.trySend(ConsoleEvent.Resize(size.columns, size.rows)) + val cols = size.columns + val rows = size.rows + if (cols < 1 || rows < 1) { + consoleFlowDebug("jline-events: ignored invalid resize columns=$cols rows=$rows") + return + } + val shouldEmit = synchronized(resizeEmitMutex) { + if (cols == lastResizeCols && rows == lastResizeRows) { + false + } else { + lastResizeCols = cols + lastResizeRows = rows + true + } + } + if (!shouldEmit) return + out.trySend(ConsoleEvent.Resize(cols, rows)) } fun cleanup() { diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 762461a..11a311d 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget group = "net.sergeych" -version = "1.5.0-RC2" +version = "1.5.0" // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below diff --git a/site/src/jsMain/resources/tetris.png b/site/src/jsMain/resources/tetris.png index bdaa62a..c4079dc 100644 Binary files a/site/src/jsMain/resources/tetris.png and b/site/src/jsMain/resources/tetris.png differ diff --git a/site/src/jsMain/resources/tetris2.png b/site/src/jsMain/resources/tetris2.png new file mode 100644 index 0000000..bdaa62a Binary files /dev/null and b/site/src/jsMain/resources/tetris2.png differ