23 changed files with 1076 additions and 0 deletions
@ -0,0 +1,33 @@
|
||||
Ниже — подтверждённые по коду проблемы; отдельно указал, что воспроизведено запуском. |
||||
|
||||
[P1] Exit смешивает TCP-потоки разных клиентов. Поиск соединения использует только stream_id, хотя каждый клиент начинает нумерацию заново. После подключения второго клиента с таким же ID данные первого могут уйти в соединение второго; FIN/CLOSE также закрывают чужой поток. Нужен ключ (src_node_id, stream_id) во всех обработчиках, включая проверку повторного CONNECT. src/proxy/tcp_proxy_server.c:342 |
||||
|
||||
[P1] Клиент не проверяет отправителя ответов. DATA/FIN/CLOSE принимаются по одному stream_id, без сравнения источника с via_node_id. Обработка CLOSE_ALL читает peer_id, но затем уничтожает вообще все клиентские соединения. Пакеты другого узла, доставленные этому сервису, могут повредить или закрыть существующие потоки. src/proxy/tcp_proxy_client.c:668 |
||||
|
||||
[P1, воспроизведено] UDP-ответы в TUN имеют неправильную checksum. Обнуляется только IP-заголовок; байты UDP checksum остаются заполненными аллокатором значением 0xAAAA. Проверка сформированного пакета дала сумму 0xC1E2 вместо 0xFFFF. Такие ответы будут отбрасываться принимающим стеком. Нужно рассчитывать checksum либо явно устанавливать ноль для IPv4. src/proxy/udp_proxy.c:188 |
||||
|
||||
[P1, воспроизведено] ICMP с нечётной длиной payload получает неверную checksum. Цикл читает последнее 16-битное слово целиком, захватывая байт за пределами выделенного пользовательского буфера. Ошибка есть и на exit, и при восстановлении ответа клиенту. Для payload длиной 2 проверочная сумма получилась FFFF, длиной 3 — FF10. Отправка (src/proxy/icmp_proxy.c:80), ответ в TUN (src/proxy/icmp_proxy.c:285) |
||||
|
||||
[P1] UDP/ICMP-контексты глобальные, а ядро поддерживает несколько instances. Следующий init() перезаписывает g_udp_ctx/g_icmp_ctx; обработчики старого экземпляра используют новый контекст, а destroy(inst) освобождает его без проверки владельца. Дополнительно UDP различает запрос и ответ только по глобальному is_exit: узел, одновременно работающий клиентом и exit, трактует входящий ответ как новый исходящий запрос. src/proxy/udp_proxy.c:137, инициализация (src/proxy/udp_proxy.c:237), src/proxy/icmp_proxy.c:321 |
||||
|
||||
[P1] Нет сквозного ограничения потока при медленном получателе. Принятые ETCP-данные без ограничения добавляются в write_queue сокета или to_lwip. Заполненность этих очередей не останавливает доставку и подтверждение данных маршрутизатором. Медленный destination или локальный клиент приводит к росту памяти; при отказе выделения TCP-данные просто теряются. src/proxy/tcp_proxy_server.c:424, src/proxy/tcp_proxy_client.c:584 |
||||
|
||||
[P1] TUN-клиент может уничтожить ещё не отправленные данные при FIN. Если локальный FIN уже получен, но tx_queue остаётся заблокированной, входящий FIN от exit вызывает conn_finish() без проверки этой очереди. Она освобождается вместе с данными. Завершать поток нужно после опустошения очередей обоих направлений. src/proxy/tcp_proxy_client.c:642 |
||||
|
||||
[P1] Exit может отправить CLOSE раньше остатка ответа. В on_fin_cb() проверка tc->fin_local имеет приоритет над pend_r. Если клиент уже закрыл свою половину, а ответ destination остался в tx_buf из-за backpressure, сервер отправляет CLOSE и освобождает остаток. Ветка on_flushed_cb() также не проверяет ожидающие отправки данные ответа. src/proxy/tcp_proxy_server.c:108 |
||||
|
||||
[P1] HTTP CONNECT зависит от границ TCP-чтений. Парсер запускает туннель после первой \r\n, не дожидаясь \r\n\r\n. Если заголовки приходят следующим чтением, они пересылаются destination как содержимое туннеля — например, перед TLS ClientHello. Если вместе с заголовками уже пришли данные туннеля, они уничтожаются обнулением buf_len. src/proxy/socks_proxy.c:195 |
||||
|
||||
[P1] HTTP POST во время DNS может потерять весь накопленный запрос. dns_pending накапливается до 65535 байт, затем отправляется одним DATA. Exit принимает максимум 8192 байта в одном сообщении и отбрасывает превышение. При переполнении самого dns_pending очередные данные также теряются без прекращения потока. Нужны ограниченная очередь и отправка частями. src/proxy/socks_proxy.c:339, накопление (src/proxy/socks_proxy.c:430), ограничение exit (src/proxy/tcp_proxy_server.c:438) |
||||
|
||||
[P2] FIN теряется в двух штатных сценариях. SOCKS/HTTP при непустой очереди ответа вызывает tcp_conn_set_flushed(tc, NULL) — продолжение, которое должно переслать FIN, отсутствует. Exit молча игнорирует FIN, пришедший до завершения TCP connect. Протоколы, ожидающие EOF перед ответом, могут зависнуть. src/proxy/socks_proxy.c:538, src/proxy/tcp_proxy_server.c:492 |
||||
|
||||
[P2] SOCKS5-парсер некорректно обрабатывает запросы. После greeting/CONNECT сбрасывается весь буфер, включая следующие байты; всегда выбирается метод NO AUTH, даже если клиент его не предлагал. IPv6 вместо отказа превращается в IPv4 из неправильного смещения buf + 12. Доменный CONNECT длиной менее 10 байт бесконечно ожидает продолжения. src/proxy/socks_proxy.c:132 |
||||
|
||||
[P2] SOCKS/HTTP сообщают об успешном соединении до подключения exit. Ответ SOCKS success или HTTP 200 формируется даже до send_connect(). Подтверждения успешного TCP connect от exit в протоколе нет. При отказе подключения клиент сначала получает успех, затем закрытие вместо корректной ошибки. src/proxy/socks_proxy.c:316 |
||||
|
||||
[P2] После RST TUN-соединение остаётся в списке. tcp_proxy_client_err_cb() обнуляет pcb и выставляет error, но не освобождает pc. Очистка предусмотрена в poll callback уже уничтоженного PCB, который больше не вызовется. src/proxy/tcp_proxy_client.c:446 |
||||
|
||||
[P2] Неправильно разбираются IP options и фрагменты в TUN. UDP/ICMP используют фиксированные смещения от 20-байтового IPv4-заголовка, игнорируя IHL и fragment offset. Пакет с options или последующий IP-фрагмент превращается в запрос с неверными портами/данными. src/proxy/tcp_proxy_client.c:158 |
||||
|
||||
[P2] ICMP-ответы разных клиентов могут перепутаться. Exit сохраняет исходные echo_id/echo_seq и ищет ответ только по этой паре, без уникального преобразования ID и проверки адреса отправителя. Совпадающие ping-запросы разных клиентов получают чужие ответы. src/proxy/icmp_proxy.c:56 |
||||
@ -0,0 +1,7 @@
|
||||
build/ |
||||
.gradle/ |
||||
*.apk |
||||
*.iml |
||||
.idea/ |
||||
local.properties |
||||
.cxx/ |
||||
@ -0,0 +1,49 @@
|
||||
plugins { |
||||
id("com.android.application") |
||||
id("org.jetbrains.kotlin.android") |
||||
} |
||||
|
||||
android { |
||||
namespace = "com.utun.lightsout" |
||||
compileSdk = 36 |
||||
|
||||
compileOptions { |
||||
sourceCompatibility = JavaVersion.VERSION_17 |
||||
targetCompatibility = JavaVersion.VERSION_17 |
||||
} |
||||
|
||||
kotlinOptions { |
||||
jvmTarget = "17" |
||||
} |
||||
|
||||
defaultConfig { |
||||
applicationId = "com.utun.lightsout" |
||||
minSdk = 26 |
||||
targetSdk = 36 |
||||
versionCode = 1 |
||||
versionName = "0.1.0" |
||||
} |
||||
|
||||
buildFeatures { |
||||
compose = true |
||||
} |
||||
|
||||
composeOptions { |
||||
kotlinCompilerExtensionVersion = "1.5.5" |
||||
} |
||||
|
||||
buildTypes { |
||||
release { |
||||
isMinifyEnabled = false |
||||
} |
||||
} |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(platform("androidx.compose:compose-bom:2024.02.00")) |
||||
implementation("androidx.compose.ui:ui") |
||||
implementation("androidx.compose.ui:ui-tooling-preview") |
||||
implementation("androidx.compose.material3:material3") |
||||
implementation("androidx.compose.foundation:foundation") |
||||
implementation("androidx.activity:activity-compose:1.8.2") |
||||
} |
||||
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
|
||||
<application |
||||
android:allowBackup="true" |
||||
android:label="@string/app_name" |
||||
android:supportsRtl="true" |
||||
android:theme="@style/Theme.LightsOut"> |
||||
|
||||
<activity |
||||
android:name=".MainActivity" |
||||
android:exported="true" |
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> |
||||
<intent-filter> |
||||
<action android:name="android.intent.action.MAIN" /> |
||||
<category android:name="android.intent.category.LAUNCHER" /> |
||||
</intent-filter> |
||||
</activity> |
||||
</application> |
||||
</manifest> |
||||
@ -0,0 +1,77 @@
|
||||
package com.utun.lightsout |
||||
|
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableIntStateOf |
||||
import androidx.compose.runtime.mutableStateListOf |
||||
import androidx.compose.runtime.setValue |
||||
import kotlin.random.Random |
||||
|
||||
// Логика игры «Выключить свет»: хранит истинные состояния клеток (real, 0/1), |
||||
// отображаемое значение каждой клетки — XOR её самой с четырьмя соседями. |
||||
// Поле хранится как Compose-наблюдаемый список, чтобы клики перерисовывали доску. |
||||
class LightsOutGame(initialSize: Int = 5) { |
||||
var size: Int = initialSize |
||||
private set |
||||
|
||||
val real = mutableStateListOf<Int>() |
||||
|
||||
var moves by mutableIntStateOf(0) |
||||
private set |
||||
|
||||
init { |
||||
newGame(initialSize) |
||||
} |
||||
|
||||
// Новая партия: сброс поля и случайная решаемая позиция. |
||||
fun newGame(size: Int) { |
||||
this.size = size |
||||
real.clear() |
||||
repeat(size * size) { real.add(0) } |
||||
moves = 0 |
||||
scramble() |
||||
} |
||||
|
||||
// Переключает клетку, инкрементирует счётчик, возвращает true, если решено. |
||||
fun toggle(row: Int, col: Int): Boolean { |
||||
val idx = row * size + col |
||||
real[idx] = real[idx] xor 1 |
||||
moves++ |
||||
return isSolved() |
||||
} |
||||
|
||||
// Отображаемое значение: XOR клетки с четырьмя соседями (с учётом границ). |
||||
fun display(row: Int, col: Int): Int { |
||||
var v = real[row * size + col] |
||||
if (row > 0) v = v xor real[(row - 1) * size + col] |
||||
if (row < size - 1) v = v xor real[(row + 1) * size + col] |
||||
if (col > 0) v = v xor real[row * size + (col - 1)] |
||||
if (col < size - 1) v = v xor real[row * size + (col + 1)] |
||||
return v |
||||
} |
||||
|
||||
// Истинное состояние клетки (для подсказки). |
||||
fun isReallyOff(row: Int, col: Int): Boolean = real[row * size + col] == 0 |
||||
|
||||
// Победа — все отображаемые клетки погашены. |
||||
fun isSolved(): Boolean { |
||||
for (r in 0 until size) { |
||||
for (c in 0 until size) { |
||||
if (display(r, c) != 0) return false |
||||
} |
||||
} |
||||
return true |
||||
} |
||||
|
||||
// Позиция строится случайными одиночными переключениями из нулевого состояния — |
||||
// обратная последовательность ходов гарантированно решает её. |
||||
private fun scramble() { |
||||
do { |
||||
for (i in real.indices) real[i] = 0 |
||||
val scrambles = size * size * 3 |
||||
repeat(scrambles) { |
||||
val idx = Random.nextInt(size * size) |
||||
real[idx] = real[idx] xor 1 |
||||
} |
||||
} while (isSolved()) |
||||
} |
||||
} |
||||
@ -0,0 +1,206 @@
|
||||
package com.utun.lightsout |
||||
|
||||
import android.os.Bundle |
||||
import androidx.activity.ComponentActivity |
||||
import androidx.activity.compose.setContent |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.border |
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Arrangement |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.aspectRatio |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
import androidx.compose.foundation.layout.fillMaxSize |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material3.AlertDialog |
||||
import androidx.compose.material3.Button |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.material3.OutlinedButton |
||||
import androidx.compose.material3.Slider |
||||
import androidx.compose.material3.Text |
||||
import androidx.compose.material3.TextButton |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableIntStateOf |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.clip |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.unit.dp |
||||
import kotlin.math.roundToInt |
||||
|
||||
private val OnColor = Color(0xFFFFD65C) // «включена» — тёплый жёлтый |
||||
private val OffColor = Color(0xFF2C3038) // «выключена» — тёмно-серый |
||||
private val HintColor = Color(0xFF78DC78) // подсказка — зелёная точка |
||||
|
||||
class MainActivity : ComponentActivity() { |
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
super.onCreate(savedInstanceState) |
||||
setContent { |
||||
MaterialTheme { |
||||
LightsOutApp() |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
fun LightsOutApp() { |
||||
var game by remember { mutableStateOf<LightsOutGame?>(null) } |
||||
var hintEnabled by remember { mutableStateOf(false) } |
||||
var showDialog by remember { mutableStateOf(true) } |
||||
var solved by remember { mutableStateOf(false) } |
||||
|
||||
// Диалог выбора размера: при старте и по кнопке «Новая игра». |
||||
if (showDialog || game == null) { |
||||
val current = game?.size ?: 5 |
||||
var selected by remember(current) { mutableIntStateOf(current) } |
||||
val isInitial = game == null |
||||
AlertDialog( |
||||
onDismissRequest = { if (!isInitial) showDialog = false }, |
||||
title = { Text(if (isInitial) "Выключить свет" else "Новая игра") }, |
||||
text = { |
||||
Column( |
||||
modifier = Modifier.fillMaxWidth(), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Text( |
||||
"$selected × $selected", |
||||
style = MaterialTheme.typography.headlineMedium |
||||
) |
||||
Slider( |
||||
value = selected.toFloat(), |
||||
onValueChange = { selected = it.roundToInt() }, |
||||
valueRange = 3f..8f, |
||||
steps = 4 |
||||
) |
||||
Text( |
||||
"Размер поля", |
||||
style = MaterialTheme.typography.bodyMedium |
||||
) |
||||
} |
||||
}, |
||||
confirmButton = { |
||||
TextButton(onClick = { |
||||
game = LightsOutGame(selected) |
||||
hintEnabled = false |
||||
solved = false |
||||
showDialog = false |
||||
}) { |
||||
Text(if (isInitial) "Играть" else "Начать") |
||||
} |
||||
}, |
||||
dismissButton = { |
||||
if (!isInitial) { |
||||
TextButton(onClick = { showDialog = false }) { Text("Отмена") } |
||||
} |
||||
} |
||||
) |
||||
} |
||||
|
||||
game?.let { g -> |
||||
Column( |
||||
modifier = Modifier |
||||
.fillMaxSize() |
||||
.padding(16.dp), |
||||
horizontalAlignment = Alignment.CenterHorizontally |
||||
) { |
||||
Text("Ходы: ${g.moves}", style = MaterialTheme.typography.titleLarge) |
||||
Spacer(Modifier.height(16.dp)) |
||||
|
||||
Board( |
||||
game = g, |
||||
hintEnabled = hintEnabled, |
||||
onCellTap = { r, c -> |
||||
if (g.toggle(r, c)) { |
||||
solved = true |
||||
} |
||||
} |
||||
) |
||||
|
||||
Spacer(Modifier.height(16.dp)) |
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
||||
OutlinedButton(onClick = { hintEnabled = !hintEnabled }) { |
||||
Text(if (hintEnabled) "Скрыть подсказку" else "Подсказка") |
||||
} |
||||
Button(onClick = { showDialog = true }) { |
||||
Text("Новая игра") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (solved) { |
||||
val g = game ?: return@LightsOutApp |
||||
AlertDialog( |
||||
onDismissRequest = { solved = false }, |
||||
title = { Text("Победа!") }, |
||||
text = { Text("Свет выключен за ${g.moves} ходов.") }, |
||||
confirmButton = { |
||||
Button(onClick = { showDialog = true }) { Text("Новая игра") } |
||||
}, |
||||
dismissButton = { |
||||
TextButton(onClick = { solved = false }) { Text("Ок") } |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun Board( |
||||
game: LightsOutGame, |
||||
hintEnabled: Boolean, |
||||
onCellTap: (Int, Int) -> Unit |
||||
) { |
||||
val n = game.size |
||||
Column( |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.aspectRatio(1f), |
||||
verticalArrangement = Arrangement.spacedBy(4.dp) |
||||
) { |
||||
for (r in 0 until n) { |
||||
Row( |
||||
modifier = Modifier |
||||
.weight(1f) |
||||
.fillMaxWidth(), |
||||
horizontalArrangement = Arrangement.spacedBy(4.dp) |
||||
) { |
||||
for (c in 0 until n) { |
||||
val on = game.display(r, c) != 0 |
||||
val hint = hintEnabled && game.isReallyOff(r, c) |
||||
Box( |
||||
modifier = Modifier |
||||
.weight(1f) |
||||
.fillMaxHeight() |
||||
.clip(RoundedCornerShape(6.dp)) |
||||
.background(if (on) OnColor else OffColor) |
||||
.border(1.dp, Color(0xFF14161A), RoundedCornerShape(6.dp)) |
||||
.clickable { onCellTap(r, c) }, |
||||
contentAlignment = Alignment.Center |
||||
) { |
||||
if (hint) { |
||||
Box( |
||||
modifier = Modifier |
||||
.size(18.dp) |
||||
.clip(CircleShape) |
||||
.background(HintColor) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<resources> |
||||
<string name="app_name">Выключить свет</string> |
||||
</resources> |
||||
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<resources> |
||||
<style name="Theme.LightsOut" parent="android:Theme.Material.Light.NoActionBar" /> |
||||
</resources> |
||||
@ -0,0 +1,4 @@
|
||||
plugins { |
||||
id("com.android.application") version "8.2.0" apply false |
||||
id("org.jetbrains.kotlin.android") version "1.9.20" apply false |
||||
} |
||||
@ -0,0 +1,49 @@
|
||||
#!/bin/bash |
||||
# Сборка и прошивка игры «Выключить свет» (Android). |
||||
# Использование: |
||||
# ./build.sh # clean + сборка + установка на все подключённые устройства |
||||
# ./build.sh noinstall # только сборка, без прошивки |
||||
# ./build.sh <SN> # clean + сборка + прошивка на конкретный девайс |
||||
set -eo pipefail |
||||
cd "$(dirname "$0")" |
||||
export ANDROID_HOME="${ANDROID_HOME:-/home/user/android}" |
||||
APK=app/build/outputs/apk/debug/app-debug.apk |
||||
|
||||
echo "Building..." |
||||
TMP=$(mktemp) |
||||
./gradlew clean > /dev/null 2>&1 |
||||
if ! ./gradlew assembleDebug > "$TMP" 2>&1; then |
||||
cat "$TMP" |
||||
rm -f "$TMP" |
||||
exit 1 |
||||
fi |
||||
rm -f "$TMP" |
||||
echo "Build OK" |
||||
|
||||
[ "$1" = "noinstall" ] && exit 0 |
||||
|
||||
if [ -n "$1" ]; then |
||||
DEVICES=("$1") |
||||
else |
||||
mapfile -t DEVICES < <(adb devices | awk 'NR>1 && $2=="device"{print $1}') |
||||
fi |
||||
|
||||
if [ ${#DEVICES[@]} -eq 0 ]; then |
||||
echo "No devices connected" >&2 |
||||
exit 1 |
||||
fi |
||||
|
||||
echo "Devices (${#DEVICES[@]}):" |
||||
i=1 |
||||
for d in "${DEVICES[@]}"; do |
||||
name=$(adb -s "$d" shell getprop ro.product.model 2>/dev/null | tr -d '\r') |
||||
[ -z "$name" ] && name=$(adb -s "$d" shell getprop ro.product.marketname 2>/dev/null | tr -d '\r') |
||||
echo " $i) $d $name" |
||||
i=$((i+1)) |
||||
done |
||||
|
||||
for d in "${DEVICES[@]}"; do |
||||
echo "Installing on $d..." |
||||
adb -s "$d" install -r "$APK" |
||||
done |
||||
echo "Install OK" |
||||
@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true |
||||
kotlin.code.style=official |
||||
org.gradle.jvmargs=-Xmx2g |
||||
Binary file not shown.
@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME |
||||
distributionPath=wrapper/dists |
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip |
||||
networkTimeout=10000 |
||||
validateDistributionUrl=true |
||||
zipStoreBase=GRADLE_USER_HOME |
||||
zipStorePath=wrapper/dists |
||||
@ -0,0 +1,249 @@
|
||||
#!/bin/sh |
||||
|
||||
# |
||||
# Copyright © 2015-2021 the original authors. |
||||
# |
||||
# Licensed under the Apache License, Version 2.0 (the "License"); |
||||
# you may not use this file except in compliance with the License. |
||||
# You may obtain a copy of the License at |
||||
# |
||||
# https://www.apache.org/licenses/LICENSE-2.0 |
||||
# |
||||
# Unless required by applicable law or agreed to in writing, software |
||||
# distributed under the License is distributed on an "AS IS" BASIS, |
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
# See the License for the specific language governing permissions and |
||||
# limitations under the License. |
||||
# |
||||
|
||||
############################################################################## |
||||
# |
||||
# Gradle start up script for POSIX generated by Gradle. |
||||
# |
||||
# Important for running: |
||||
# |
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is |
||||
# noncompliant, but you have some other compliant shell such as ksh or |
||||
# bash, then to run this script, type that shell name before the whole |
||||
# command line, like: |
||||
# |
||||
# ksh Gradle |
||||
# |
||||
# Busybox and similar reduced shells will NOT work, because this script |
||||
# requires all of these POSIX shell features: |
||||
# * functions; |
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», |
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»; |
||||
# * compound commands having a testable exit status, especially «case»; |
||||
# * various built-in commands including «command», «set», and «ulimit». |
||||
# |
||||
# Important for patching: |
||||
# |
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided |
||||
# by Bash, Ksh, etc; in particular arrays are avoided. |
||||
# |
||||
# The "traditional" practice of packing multiple parameters into a |
||||
# space-separated string is a well documented source of bugs and security |
||||
# problems, so this is (mostly) avoided, by progressively accumulating |
||||
# options in "$@", and eventually passing that to Java. |
||||
# |
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, |
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; |
||||
# see the in-line comments for details. |
||||
# |
||||
# There are tweaks for specific operating systems such as AIX, CygWin, |
||||
# Darwin, MinGW, and NonStop. |
||||
# |
||||
# (3) This script is generated from the Groovy template |
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt |
||||
# within the Gradle project. |
||||
# |
||||
# You can find Gradle at https://github.com/gradle/gradle/. |
||||
# |
||||
############################################################################## |
||||
|
||||
# Attempt to set APP_HOME |
||||
|
||||
# Resolve links: $0 may be a link |
||||
app_path=$0 |
||||
|
||||
# Need this for daisy-chained symlinks. |
||||
while |
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path |
||||
[ -h "$app_path" ] |
||||
do |
||||
ls=$( ls -ld "$app_path" ) |
||||
link=${ls#*' -> '} |
||||
case $link in #( |
||||
/*) app_path=$link ;; #( |
||||
*) app_path=$APP_HOME$link ;; |
||||
esac |
||||
done |
||||
|
||||
# This is normally unused |
||||
# shellcheck disable=SC2034 |
||||
APP_BASE_NAME=${0##*/} |
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) |
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit |
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value. |
||||
MAX_FD=maximum |
||||
|
||||
warn () { |
||||
echo "$*" |
||||
} >&2 |
||||
|
||||
die () { |
||||
echo |
||||
echo "$*" |
||||
echo |
||||
exit 1 |
||||
} >&2 |
||||
|
||||
# OS specific support (must be 'true' or 'false'). |
||||
cygwin=false |
||||
msys=false |
||||
darwin=false |
||||
nonstop=false |
||||
case "$( uname )" in #( |
||||
CYGWIN* ) cygwin=true ;; #( |
||||
Darwin* ) darwin=true ;; #( |
||||
MSYS* | MINGW* ) msys=true ;; #( |
||||
NONSTOP* ) nonstop=true ;; |
||||
esac |
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar |
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM. |
||||
if [ -n "$JAVA_HOME" ] ; then |
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then |
||||
# IBM's JDK on AIX uses strange locations for the executables |
||||
JAVACMD=$JAVA_HOME/jre/sh/java |
||||
else |
||||
JAVACMD=$JAVA_HOME/bin/java |
||||
fi |
||||
if [ ! -x "$JAVACMD" ] ; then |
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME |
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the |
||||
location of your Java installation." |
||||
fi |
||||
else |
||||
JAVACMD=java |
||||
if ! command -v java >/dev/null 2>&1 |
||||
then |
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. |
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the |
||||
location of your Java installation." |
||||
fi |
||||
fi |
||||
|
||||
# Increase the maximum file descriptors if we can. |
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then |
||||
case $MAX_FD in #( |
||||
max*) |
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. |
||||
# shellcheck disable=SC2039,SC3045 |
||||
MAX_FD=$( ulimit -H -n ) || |
||||
warn "Could not query maximum file descriptor limit" |
||||
esac |
||||
case $MAX_FD in #( |
||||
'' | soft) :;; #( |
||||
*) |
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. |
||||
# shellcheck disable=SC2039,SC3045 |
||||
ulimit -n "$MAX_FD" || |
||||
warn "Could not set maximum file descriptor limit to $MAX_FD" |
||||
esac |
||||
fi |
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order: |
||||
# * args from the command line |
||||
# * the main class name |
||||
# * -classpath |
||||
# * -D...appname settings |
||||
# * --module-path (only if needed) |
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. |
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java |
||||
if "$cygwin" || "$msys" ; then |
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) |
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) |
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" ) |
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh |
||||
for arg do |
||||
if |
||||
case $arg in #( |
||||
-*) false ;; # don't mess with options #( |
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath |
||||
[ -e "$t" ] ;; #( |
||||
*) false ;; |
||||
esac |
||||
then |
||||
arg=$( cygpath --path --ignore --mixed "$arg" ) |
||||
fi |
||||
# Roll the args list around exactly as many times as the number of |
||||
# args, so each arg winds up back in the position where it started, but |
||||
# possibly modified. |
||||
# |
||||
# NB: a `for` loop captures its iteration list before it begins, so |
||||
# changing the positional parameters here affects neither the number of |
||||
# iterations, nor the values presented in `arg`. |
||||
shift # remove old arg |
||||
set -- "$@" "$arg" # push replacement arg |
||||
done |
||||
fi |
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. |
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' |
||||
|
||||
# Collect all arguments for the java command: |
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, |
||||
# and any embedded shellness will be escaped. |
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be |
||||
# treated as '${Hostname}' itself on the command line. |
||||
|
||||
set -- \ |
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \ |
||||
-classpath "$CLASSPATH" \ |
||||
org.gradle.wrapper.GradleWrapperMain \ |
||||
"$@" |
||||
|
||||
# Stop when "xargs" is not available. |
||||
if ! command -v xargs >/dev/null 2>&1 |
||||
then |
||||
die "xargs is not available" |
||||
fi |
||||
|
||||
# Use "xargs" to parse quoted args. |
||||
# |
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. |
||||
# |
||||
# In Bash we could simply go: |
||||
# |
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) && |
||||
# set -- "${ARGS[@]}" "$@" |
||||
# |
||||
# but POSIX shell has neither arrays nor command substitution, so instead we |
||||
# post-process each arg (as a line of input to sed) to backslash-escape any |
||||
# character that might be a shell metacharacter, then use eval to reverse |
||||
# that process (while maintaining the separation between arguments), and wrap |
||||
# the whole thing up as a single "set" statement. |
||||
# |
||||
# This will of course break if any of these variables contains a newline or |
||||
# an unmatched quote. |
||||
# |
||||
|
||||
eval "set -- $( |
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | |
||||
xargs -n1 | |
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | |
||||
tr '\n' ' ' |
||||
)" '"$@"' |
||||
|
||||
exec "$JAVACMD" "$@" |
||||
@ -0,0 +1,18 @@
|
||||
pluginManagement { |
||||
repositories { |
||||
google() |
||||
mavenCentral() |
||||
gradlePluginPortal() |
||||
} |
||||
} |
||||
|
||||
dependencyResolutionManagement { |
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) |
||||
repositories { |
||||
google() |
||||
mavenCentral() |
||||
} |
||||
} |
||||
|
||||
rootProject.name = "lightsout-android" |
||||
include(":app") |
||||
@ -0,0 +1,22 @@
|
||||
cmake_minimum_required(VERSION 3.16) |
||||
project(lightsout VERSION 0.1.0 LANGUAGES CXX) |
||||
|
||||
set(CMAKE_CXX_STANDARD 17) |
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON) |
||||
set(CMAKE_AUTOMOC ON) |
||||
|
||||
find_package(Qt6 COMPONENTS Widgets QUIET) |
||||
if(NOT Qt6_FOUND) |
||||
find_package(Qt5 COMPONENTS Widgets REQUIRED) |
||||
set(QT_LIBS Qt5::Widgets) |
||||
else() |
||||
set(QT_LIBS Qt6::Widgets) |
||||
endif() |
||||
|
||||
add_executable(lightsout |
||||
src/main.cpp |
||||
src/mainwindow.cpp |
||||
src/boardwidget.cpp |
||||
) |
||||
|
||||
target_link_libraries(lightsout PRIVATE ${QT_LIBS}) |
||||
@ -0,0 +1,8 @@
|
||||
#!/bin/sh |
||||
# Сборка игры «Выключить свет» (Qt). |
||||
set -e |
||||
cd "$(dirname "$0")" |
||||
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release |
||||
cmake --build build -j"$(nproc 2>/dev/null || echo 4)" |
||||
echo "Готово: ./build/lightsout" |
||||
@ -0,0 +1,141 @@
|
||||
#include "boardwidget.h" |
||||
|
||||
#include <QMouseEvent> |
||||
#include <QPainter> |
||||
#include <QRandomGenerator> |
||||
|
||||
namespace { |
||||
constexpr int kMargin = 8; // внешний отступ поля
|
||||
constexpr int kGap = 4; // зазор между клетками
|
||||
constexpr int kMinCell = 36; // минимальный размер клетки (пикс.)
|
||||
constexpr int kMaxCell = 72; // максимальный размер клетки (пикс.)
|
||||
} |
||||
|
||||
BoardWidget::BoardWidget(QWidget *parent) |
||||
: QWidget(parent) { |
||||
setMinimumSize(220, 220); |
||||
} |
||||
|
||||
// Создаёт новую партию: сбрасывает поле, затем случайными одиночными
|
||||
// переключениями формирует решаемую стартовую позицию.
|
||||
void BoardWidget::newGame(int size) { |
||||
m_size = size; |
||||
m_moves = 0; |
||||
m_real.fill(0, m_size * m_size); |
||||
|
||||
// Позиция строится переключением случайных клеток из нулевого состояния —
|
||||
// обратная последовательность ходов гарантированно решает её.
|
||||
const int scrambles = m_size * m_size * 3; |
||||
for (int i = 0; i < scrambles; ++i) { |
||||
const int idx = QRandomGenerator::global()->bounded(m_size * m_size); |
||||
m_real[idx] ^= 1; |
||||
} |
||||
|
||||
update(); |
||||
emit stateChanged(isSolved()); |
||||
} |
||||
|
||||
// Включает/выключает показ подсказки.
|
||||
void BoardWidget::setHintEnabled(bool enabled) { |
||||
if (m_hintEnabled == enabled) { |
||||
return; |
||||
} |
||||
m_hintEnabled = enabled; |
||||
update(); |
||||
} |
||||
|
||||
// Отображаемое значение клетки — XOR её самой с четырьмя соседями.
|
||||
int BoardWidget::displayValue(int row, int col) const { |
||||
int v = m_real[row * m_size + col]; |
||||
if (row > 0) v ^= m_real[(row - 1) * m_size + col]; |
||||
if (row < m_size - 1) v ^= m_real[(row + 1) * m_size + col]; |
||||
if (col > 0) v ^= m_real[row * m_size + (col - 1)]; |
||||
if (col < m_size - 1) v ^= m_real[row * m_size + (col + 1)]; |
||||
return v; |
||||
} |
||||
|
||||
// Переключает клетку по клику и проверяет победу.
|
||||
void BoardWidget::toggleCell(int row, int col) { |
||||
m_real[row * m_size + col] ^= 1; |
||||
++m_moves; |
||||
update(); |
||||
emit stateChanged(isSolved()); |
||||
} |
||||
|
||||
// Победа — все отображаемые клетки погашены.
|
||||
bool BoardWidget::isSolved() const { |
||||
for (int r = 0; r < m_size; ++r) { |
||||
for (int c = 0; c < m_size; ++c) { |
||||
if (displayValue(r, c) != 0) { |
||||
return false; |
||||
} |
||||
} |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
void BoardWidget::mousePressEvent(QMouseEvent *event) { |
||||
if (m_size <= 0 || event->button() != Qt::LeftButton) { |
||||
QWidget::mousePressEvent(event); |
||||
return; |
||||
} |
||||
|
||||
const QRectF board = boardRect(); |
||||
if (!board.contains(event->pos())) { |
||||
return; |
||||
} |
||||
|
||||
const double cell = (board.width() - (m_size - 1) * kGap) / m_size; |
||||
const int col = static_cast<int>((event->pos().x() - board.left()) / (cell + kGap)); |
||||
const int row = static_cast<int>((event->pos().y() - board.top()) / (cell + kGap)); |
||||
|
||||
if (row >= 0 && row < m_size && col >= 0 && col < m_size) { |
||||
toggleCell(row, col); |
||||
} |
||||
} |
||||
|
||||
void BoardWidget::paintEvent(QPaintEvent *) { |
||||
QPainter painter(this); |
||||
painter.setRenderHint(QPainter::Antialiasing); |
||||
|
||||
if (m_size <= 0) { |
||||
return; |
||||
} |
||||
|
||||
const QRectF board = boardRect(); |
||||
const double cell = (board.width() - (m_size - 1) * kGap) / m_size; |
||||
|
||||
const QColor onColor(255, 214, 92); // «включена» — тёплый жёлтый
|
||||
const QColor offColor(44, 48, 56); // «выключена» — тёмно-серый
|
||||
const QColor hintColor(120, 220, 120); // подсказка — зелёная точка
|
||||
|
||||
for (int r = 0; r < m_size; ++r) { |
||||
for (int c = 0; c < m_size; ++c) { |
||||
const QRectF rect( |
||||
board.left() + c * (cell + kGap), |
||||
board.top() + r * (cell + kGap), |
||||
cell, cell); |
||||
|
||||
painter.setPen(QPen(QColor(20, 22, 26), 1.5)); |
||||
painter.setBrush(displayValue(r, c) ? onColor : offColor); |
||||
painter.drawRoundedRect(rect, 6, 6); |
||||
|
||||
// Подсказка: точка на клетках, чьё истинное значение равно 0.
|
||||
if (m_hintEnabled && m_real[r * m_size + c] == 0) { |
||||
painter.setPen(Qt::NoPen); |
||||
painter.setBrush(hintColor); |
||||
const double d = cell * 0.22; |
||||
painter.drawEllipse(rect.center(), d / 2, d / 2); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Прямоугольник, занимаемый сеткой клеток (центрированный квадрат).
|
||||
QRectF BoardWidget::boardRect() const { |
||||
const int cell = kMinCell; |
||||
const double side = cell * m_size + kGap * (m_size - 1); |
||||
const double x = (width() - side) / 2.0; |
||||
const double y = (height() - side) / 2.0; |
||||
return QRectF(x, y, side, side); |
||||
} |
||||
@ -0,0 +1,54 @@
|
||||
#ifndef LIGHTSOUT_BOARDWIDGET_H |
||||
#define LIGHTSOUT_BOARDWIDGET_H |
||||
|
||||
#include <QWidget> |
||||
#include <QVector> |
||||
|
||||
// Виджет игрового поля: хранит истинные состояния клеток (real, 0/1),
|
||||
// отображает XOR каждой клетки с четырьмя соседями, опционально показывает
|
||||
// подсказку (точку) на клетках, чьё истинное значение равно 0.
|
||||
class BoardWidget : public QWidget { |
||||
Q_OBJECT |
||||
public: |
||||
explicit BoardWidget(QWidget *parent = nullptr); |
||||
|
||||
// Создаёт новую игру: поле size x size, случайная решаемая позиция.
|
||||
void newGame(int size); |
||||
|
||||
// Включает/выключает показ подсказки (точка на клетках real == 0).
|
||||
void setHintEnabled(bool enabled); |
||||
|
||||
// Показывается ли сейчас подсказка.
|
||||
bool isHintEnabled() const { return m_hintEnabled; } |
||||
|
||||
// Число ходов с начала партии.
|
||||
int moves() const { return m_moves; } |
||||
|
||||
signals: |
||||
// Испускается после каждого хода; solved == true, если все клетки погашены.
|
||||
void stateChanged(bool solved); |
||||
|
||||
protected: |
||||
void paintEvent(QPaintEvent *event) override; |
||||
void mousePressEvent(QMouseEvent *event) override; |
||||
|
||||
private: |
||||
// Прямоугольник, занимаемый сеткой клеток (центрированный квадрат).
|
||||
QRectF boardRect() const; |
||||
|
||||
// Отображаемое значение клетки: XOR её и четырёх соседей.
|
||||
int displayValue(int row, int col) const; |
||||
|
||||
// Переключить клетку (обрабатывает клик), пересчитать и проверить победу.
|
||||
void toggleCell(int row, int col); |
||||
|
||||
// Проверяет, все ли отображаемые клетки погашены.
|
||||
bool isSolved() const; |
||||
|
||||
int m_size = 0; // размер поля (3..8)
|
||||
QVector<quint8> m_real; // истинное состояние клеток
|
||||
bool m_hintEnabled = false; // показывать подсказку
|
||||
int m_moves = 0; // число ходов
|
||||
}; |
||||
|
||||
#endif // LIGHTSOUT_BOARDWIDGET_H
|
||||
@ -0,0 +1,21 @@
|
||||
#include "mainwindow.h" |
||||
|
||||
#include <QApplication> |
||||
#include <QInputDialog> |
||||
|
||||
// Точка входа: запрашивает размер поля (3–8) и запускает игру.
|
||||
int main(int argc, char *argv[]) { |
||||
QApplication app(argc, argv); |
||||
|
||||
bool ok = false; |
||||
const int size = QInputDialog::getInt( |
||||
nullptr, QObject::tr("Выключить свет"), |
||||
QObject::tr("Размер поля (3–8):"), 5, 3, 8, 1, &ok); |
||||
if (!ok) { |
||||
return 0; |
||||
} |
||||
|
||||
MainWindow window(size); |
||||
window.show(); |
||||
return app.exec(); |
||||
} |
||||
@ -0,0 +1,73 @@
|
||||
#include "mainwindow.h" |
||||
#include "boardwidget.h" |
||||
|
||||
#include <QInputDialog> |
||||
#include <QLabel> |
||||
#include <QMenuBar> |
||||
#include <QMessageBox> |
||||
#include <QPushButton> |
||||
#include <QToolBar> |
||||
#include <QVBoxLayout> |
||||
#include <QWidget> |
||||
|
||||
MainWindow::MainWindow(int size, QWidget *parent) |
||||
: QMainWindow(parent), m_size(size) { |
||||
setWindowTitle(QStringLiteral("Выключить свет")); |
||||
|
||||
m_board = new BoardWidget(this); |
||||
m_board->newGame(m_size); |
||||
|
||||
m_movesLabel = new QLabel(this); |
||||
m_movesLabel->setText(tr("Ходы: 0")); |
||||
|
||||
auto *hintButton = new QPushButton(tr("Подсказка"), this); |
||||
hintButton->setCheckable(true); |
||||
connect(hintButton, &QPushButton::toggled, this, &MainWindow::toggleHint); |
||||
|
||||
auto *newButton = new QPushButton(tr("Новая игра"), this); |
||||
connect(newButton, &QPushButton::clicked, this, &MainWindow::newGame); |
||||
|
||||
auto *controls = new QWidget(this); |
||||
auto *layout = new QVBoxLayout(controls); |
||||
layout->addWidget(m_board, 1); |
||||
layout->addWidget(m_movesLabel); |
||||
layout->addWidget(hintButton); |
||||
layout->addWidget(newButton); |
||||
setCentralWidget(controls); |
||||
|
||||
connect(m_board, &BoardWidget::stateChanged, this, &MainWindow::onBoardStateChanged); |
||||
} |
||||
|
||||
// Начинает новую партию, запрашивая размер поля у пользователя.
|
||||
void MainWindow::newGame() { |
||||
bool ok = false; |
||||
const int size = QInputDialog::getInt( |
||||
this, tr("Новая игра"), |
||||
tr("Размер поля (3–8):"), m_size, 3, 8, 1, &ok); |
||||
if (!ok) { |
||||
return; |
||||
} |
||||
|
||||
m_size = size; |
||||
m_board->newGame(m_size); |
||||
m_movesLabel->setText(tr("Ходы: 0")); |
||||
} |
||||
|
||||
// Переключает показ подсказок.
|
||||
void MainWindow::toggleHint() { |
||||
m_board->setHintEnabled(!m_board->isHintEnabled()); |
||||
} |
||||
|
||||
// Обновляет счётчик ходов и сообщает о победе.
|
||||
void MainWindow::onBoardStateChanged(bool solved) { |
||||
m_movesLabel->setText(tr("Ходы: %1").arg(m_board->moves())); |
||||
if (solved) { |
||||
const auto ret = QMessageBox::information( |
||||
this, tr("Победа!"), |
||||
tr("Свет выключен за %1 ходов.").arg(m_board->moves()), |
||||
QMessageBox::Ok | QMessageBox::Reset, QMessageBox::Ok); |
||||
if (ret == QMessageBox::Reset) { |
||||
newGame(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
|
||||
#ifndef LIGHTSOUT_MAINWINDOW_H |
||||
#define LIGHTSOUT_MAINWINDOW_H |
||||
|
||||
#include <QMainWindow> |
||||
|
||||
class BoardWidget; |
||||
class QLabel; |
||||
|
||||
// Главное окно игры: доска, кнопки «Новая игра»/«Подсказка», счётчик ходов.
|
||||
class MainWindow : public QMainWindow { |
||||
Q_OBJECT |
||||
public: |
||||
explicit MainWindow(int size, QWidget *parent = nullptr); |
||||
|
||||
private slots: |
||||
void newGame(); |
||||
void toggleHint(); |
||||
void onBoardStateChanged(bool solved); |
||||
|
||||
private: |
||||
int m_size; |
||||
BoardWidget *m_board; |
||||
QLabel *m_movesLabel; |
||||
}; |
||||
|
||||
#endif // LIGHTSOUT_MAINWINDOW_H
|
||||
Loading…
Reference in new issue