mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Add tic tac toe DM game
This commit is contained in:
@@ -27,6 +27,7 @@ import '../utils/toast_logger.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import '../utils/voice_message_parser.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
import '../utils/tictactoe_message_parser.dart';
|
||||
import '../providers/image_provider.dart' as ip;
|
||||
import '../services/image_codec_service.dart';
|
||||
import '../services/image_preferences.dart';
|
||||
@@ -428,6 +429,54 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startTicTacToeGame() async {
|
||||
if (!mounted) return;
|
||||
if (_destinationType !=
|
||||
MessageDestinationPreferences.destinationTypeContact ||
|
||||
_selectedRecipient == null) {
|
||||
ToastLogger.warning(
|
||||
context,
|
||||
'Tic-Tac-Toe works only in direct messages. Choose a contact first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
ToastLogger.error(context, 'Not connected to device');
|
||||
return;
|
||||
}
|
||||
|
||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
||||
if (devicePublicKey == null || devicePublicKey.length < 6) {
|
||||
ToastLogger.error(context, 'Device key unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
final gameId = List.generate(
|
||||
8,
|
||||
(_) => math.Random.secure().nextInt(16).toRadixString(16),
|
||||
).join();
|
||||
final starterKey6 = devicePublicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('');
|
||||
final startMessage = TicTacToeMessageParser.encodeStart(
|
||||
gameId: gameId,
|
||||
starterKey6: starterKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
|
||||
await _sendToRecipient(
|
||||
startMessage,
|
||||
connectionProvider,
|
||||
messagesProvider,
|
||||
contactsProvider,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image sending ───────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _pickAndSendImage({
|
||||
@@ -1077,6 +1126,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_pickAndSendImage(source: ImageSource.camera);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.grid_3x3),
|
||||
title: const Text('Start Tic-Tac-Toe'),
|
||||
subtitle: const Text('DM only'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_startTicTacToeGame();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
163
lib/utils/tictactoe_message_parser.dart
Normal file
163
lib/utils/tictactoe_message_parser.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
enum TicTacToeEventType { start, move }
|
||||
|
||||
class TicTacToeEvent {
|
||||
final TicTacToeEventType type;
|
||||
final String gameId;
|
||||
final String playerKey6;
|
||||
final int? cell;
|
||||
final int timestampSec;
|
||||
|
||||
const TicTacToeEvent({
|
||||
required this.type,
|
||||
required this.gameId,
|
||||
required this.playerKey6,
|
||||
this.cell,
|
||||
required this.timestampSec,
|
||||
});
|
||||
}
|
||||
|
||||
class TicTacToeMessageParser {
|
||||
static const String _prefix = 'TTT1:';
|
||||
|
||||
static bool isTicTacToe(String text) => text.startsWith(_prefix);
|
||||
|
||||
static TicTacToeEvent? tryParse(String text) {
|
||||
if (!isTicTacToe(text)) return null;
|
||||
final body = text.substring(_prefix.length);
|
||||
final parts = body.split(':');
|
||||
if (parts.length < 4) return null;
|
||||
|
||||
final action = parts[0];
|
||||
final gameId = parts[1].toLowerCase();
|
||||
if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(gameId)) return null;
|
||||
|
||||
if (action == 'S' && parts.length == 4) {
|
||||
final starterKey6 = parts[2].toLowerCase();
|
||||
final ts = int.tryParse(parts[3]);
|
||||
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(starterKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
return TicTacToeEvent(
|
||||
type: TicTacToeEventType.start,
|
||||
gameId: gameId,
|
||||
playerKey6: starterKey6,
|
||||
timestampSec: ts,
|
||||
);
|
||||
}
|
||||
|
||||
if (action == 'M' && parts.length == 5) {
|
||||
final cell = int.tryParse(parts[2]);
|
||||
final playerKey6 = parts[3].toLowerCase();
|
||||
final ts = int.tryParse(parts[4]);
|
||||
if (cell == null || cell < 0 || cell > 8) return null;
|
||||
if (!RegExp(r'^[0-9a-f]{12}$').hasMatch(playerKey6)) return null;
|
||||
if (ts == null || ts <= 0) return null;
|
||||
return TicTacToeEvent(
|
||||
type: TicTacToeEventType.move,
|
||||
gameId: gameId,
|
||||
playerKey6: playerKey6,
|
||||
cell: cell,
|
||||
timestampSec: ts,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String encodeStart({
|
||||
required String gameId,
|
||||
required String starterKey6,
|
||||
required int timestampSec,
|
||||
}) {
|
||||
return '$_prefix'
|
||||
'S:${gameId.toLowerCase()}:${starterKey6.toLowerCase()}:$timestampSec';
|
||||
}
|
||||
|
||||
static String encodeMove({
|
||||
required String gameId,
|
||||
required int cell,
|
||||
required String playerKey6,
|
||||
required int timestampSec,
|
||||
}) {
|
||||
return '$_prefix'
|
||||
'M:${gameId.toLowerCase()}:$cell:${playerKey6.toLowerCase()}:$timestampSec';
|
||||
}
|
||||
}
|
||||
|
||||
class TicTacToeGameState {
|
||||
final String gameId;
|
||||
final String xPlayerKey6;
|
||||
final String oPlayerKey6;
|
||||
final List<String?> board; // 'X' / 'O' / null
|
||||
final String nextSymbol;
|
||||
final String? winnerSymbol;
|
||||
|
||||
const TicTacToeGameState({
|
||||
required this.gameId,
|
||||
required this.xPlayerKey6,
|
||||
required this.oPlayerKey6,
|
||||
required this.board,
|
||||
required this.nextSymbol,
|
||||
this.winnerSymbol,
|
||||
});
|
||||
|
||||
bool get isDraw =>
|
||||
winnerSymbol == null && board.every((cell) => cell != null);
|
||||
bool get isFinished => winnerSymbol != null || isDraw;
|
||||
}
|
||||
|
||||
TicTacToeGameState buildTicTacToeState({
|
||||
required String gameId,
|
||||
required String xPlayerKey6,
|
||||
required String oPlayerKey6,
|
||||
required List<TicTacToeEvent> events,
|
||||
}) {
|
||||
final board = List<String?>.filled(9, null);
|
||||
var next = 'X';
|
||||
String? winner;
|
||||
|
||||
final sorted = [...events]
|
||||
..sort((a, b) => a.timestampSec.compareTo(b.timestampSec));
|
||||
|
||||
for (final event in sorted) {
|
||||
if (event.type != TicTacToeEventType.move) continue;
|
||||
if (winner != null) break;
|
||||
|
||||
final expectedKey = next == 'X' ? xPlayerKey6 : oPlayerKey6;
|
||||
final cell = event.cell;
|
||||
if (cell == null) continue;
|
||||
if (event.playerKey6 != expectedKey) continue;
|
||||
if (board[cell] != null) continue;
|
||||
|
||||
board[cell] = next;
|
||||
winner = _computeWinner(board);
|
||||
next = next == 'X' ? 'O' : 'X';
|
||||
}
|
||||
|
||||
return TicTacToeGameState(
|
||||
gameId: gameId,
|
||||
xPlayerKey6: xPlayerKey6,
|
||||
oPlayerKey6: oPlayerKey6,
|
||||
board: board,
|
||||
nextSymbol: next,
|
||||
winnerSymbol: winner,
|
||||
);
|
||||
}
|
||||
|
||||
String? _computeWinner(List<String?> board) {
|
||||
const lines = <List<int>>[
|
||||
[0, 1, 2],
|
||||
[3, 4, 5],
|
||||
[6, 7, 8],
|
||||
[0, 3, 6],
|
||||
[1, 4, 7],
|
||||
[2, 5, 8],
|
||||
[0, 4, 8],
|
||||
[2, 4, 6],
|
||||
];
|
||||
for (final line in lines) {
|
||||
final a = board[line[0]];
|
||||
if (a == null) continue;
|
||||
if (a == board[line[1]] && a == board[line[2]]) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -22,10 +22,12 @@ import '../../utils/sar_message_parser.dart';
|
||||
import '../../utils/key_comparison.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/message_extensions.dart';
|
||||
import 'voice_message_bubble.dart';
|
||||
import 'image_message_bubble.dart';
|
||||
import 'tictactoe_message_bubble.dart';
|
||||
import 'message_trace_sheet.dart';
|
||||
|
||||
/// Reusable message bubble widget that displays messages with various types:
|
||||
@@ -2180,6 +2182,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
||||
else if (ImageEnvelope.isEnvelope(message.text) &&
|
||||
!widget.isCompact)
|
||||
ImageMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Tic-Tac-Toe control message content
|
||||
else if (message.isContactMessage &&
|
||||
TicTacToeMessageParser.isTicTacToe(message.text) &&
|
||||
!widget.isCompact)
|
||||
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
||||
// Regular message content
|
||||
else if (!message.isDrawing || widget.isCompact)
|
||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
||||
|
||||
268
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
268
lib/widgets/messages/tictactoe_message_bubble.dart
Normal file
@@ -0,0 +1,268 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../models/message.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
import '../../providers/messages_provider.dart';
|
||||
import '../../utils/tictactoe_message_parser.dart';
|
||||
import '../../utils/toast_logger.dart';
|
||||
|
||||
class TicTacToeMessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool isSentByMe;
|
||||
|
||||
const TicTacToeMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.isSentByMe,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final event = TicTacToeMessageParser.tryParse(message.text);
|
||||
if (event == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final conn = context.watch<ConnectionProvider>();
|
||||
final contacts = context.read<ContactsProvider>();
|
||||
final messages = context.watch<MessagesProvider>().messages;
|
||||
final selfKey = conn.deviceInfo.publicKey;
|
||||
if (selfKey == null || selfKey.length < 6) {
|
||||
return const Text('Tic-Tac-Toe unavailable');
|
||||
}
|
||||
|
||||
final selfKey6 = _key6Hex(selfKey);
|
||||
final opponent = _resolveOpponentContact(
|
||||
message: message,
|
||||
contactsProvider: contacts,
|
||||
isSentByMe: isSentByMe,
|
||||
);
|
||||
if (opponent == null) {
|
||||
return const Text('Tic-Tac-Toe: opponent unknown');
|
||||
}
|
||||
final opponentKey6 = _key6Hex(opponent.publicKey);
|
||||
|
||||
final gameEvents = <TicTacToeEvent>[];
|
||||
TicTacToeEvent? start;
|
||||
for (final m in messages) {
|
||||
if (!m.isContactMessage) continue;
|
||||
final parsed = TicTacToeMessageParser.tryParse(m.text);
|
||||
if (parsed == null || parsed.gameId != event.gameId) continue;
|
||||
if (!_isSameDmThread(
|
||||
message: m,
|
||||
selfKey: selfKey,
|
||||
opponentKey6: opponentKey6,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.type == TicTacToeEventType.start) {
|
||||
start ??= parsed;
|
||||
} else {
|
||||
gameEvents.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
start ??= event.type == TicTacToeEventType.start ? event : null;
|
||||
if (start == null) {
|
||||
return const Text('Tic-Tac-Toe: waiting for start');
|
||||
}
|
||||
|
||||
final xPlayer = start.playerKey6;
|
||||
final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6;
|
||||
final state = buildTicTacToeState(
|
||||
gameId: event.gameId,
|
||||
xPlayerKey6: xPlayer,
|
||||
oPlayerKey6: oPlayer,
|
||||
events: gameEvents,
|
||||
);
|
||||
|
||||
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
|
||||
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 230),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_BoardGrid(
|
||||
board: state.board,
|
||||
enabled: isMyTurn,
|
||||
onTapCell: (idx) => _onCellTap(
|
||||
context: context,
|
||||
idx: idx,
|
||||
state: state,
|
||||
selfKey6: selfKey6,
|
||||
opponent: opponent,
|
||||
connectionProvider: conn,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_statusText(state: state, mySymbol: mySymbol),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCellTap({
|
||||
required BuildContext context,
|
||||
required int idx,
|
||||
required TicTacToeGameState state,
|
||||
required String selfKey6,
|
||||
required Contact opponent,
|
||||
required ConnectionProvider connectionProvider,
|
||||
}) async {
|
||||
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
ToastLogger.error(context, 'Not connected to device');
|
||||
return;
|
||||
}
|
||||
|
||||
final messagesProvider = context.read<MessagesProvider>();
|
||||
final text = TicTacToeMessageParser.encodeMove(
|
||||
gameId: state.gameId,
|
||||
cell: idx,
|
||||
playerKey6: selfKey6,
|
||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
);
|
||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move';
|
||||
final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey!
|
||||
.sublist(0, 6);
|
||||
|
||||
final sentMessage = Message(
|
||||
id: messageId,
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
text: text,
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: opponent.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: opponent.publicKey,
|
||||
text: text,
|
||||
messageId: messageId,
|
||||
contact: opponent,
|
||||
);
|
||||
if (!sent) {
|
||||
messagesProvider.markMessageFailed(messageId);
|
||||
if (!context.mounted) return;
|
||||
ToastLogger.error(context, 'Failed to send Tic-Tac-Toe move');
|
||||
}
|
||||
}
|
||||
|
||||
static String _statusText({
|
||||
required TicTacToeGameState state,
|
||||
required String mySymbol,
|
||||
}) {
|
||||
if (state.winnerSymbol != null) {
|
||||
return state.winnerSymbol == mySymbol ? 'You won' : 'Opponent won';
|
||||
}
|
||||
if (state.isDraw) return 'Draw';
|
||||
return state.nextSymbol == mySymbol ? 'Your turn' : 'Opponent turn';
|
||||
}
|
||||
|
||||
static String _key6Hex(Uint8List key) => key
|
||||
.sublist(0, math.min(6, key.length))
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join('')
|
||||
.toLowerCase();
|
||||
|
||||
static Contact? _resolveOpponentContact({
|
||||
required Message message,
|
||||
required ContactsProvider contactsProvider,
|
||||
required bool isSentByMe,
|
||||
}) {
|
||||
if (isSentByMe && message.recipientPublicKey != null) {
|
||||
return contactsProvider.findContactByKey(message.recipientPublicKey!);
|
||||
}
|
||||
final sender = message.senderPublicKeyPrefix;
|
||||
if (sender == null || sender.length < 6) return null;
|
||||
return contactsProvider.findContactByPrefix(
|
||||
Uint8List.fromList(sender.sublist(0, 6)),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isSameDmThread({
|
||||
required Message message,
|
||||
required Uint8List selfKey,
|
||||
required String opponentKey6,
|
||||
}) {
|
||||
final isOwn = message.isSentMessage || message.isFromSelf(selfKey);
|
||||
if (isOwn) {
|
||||
final recipient = message.recipientPublicKey;
|
||||
if (recipient == null || recipient.length < 6) return false;
|
||||
return _key6Hex(recipient) == opponentKey6;
|
||||
}
|
||||
final sender = message.senderPublicKeyPrefix;
|
||||
if (sender == null || sender.length < 6) return false;
|
||||
return _key6Hex(sender) == opponentKey6;
|
||||
}
|
||||
}
|
||||
|
||||
class _BoardGrid extends StatelessWidget {
|
||||
final List<String?> board;
|
||||
final bool enabled;
|
||||
final ValueChanged<int> onTapCell;
|
||||
|
||||
const _BoardGrid({
|
||||
required this.board,
|
||||
required this.enabled,
|
||||
required this.onTapCell,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
height: 180,
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
),
|
||||
itemCount: 9,
|
||||
itemBuilder: (context, idx) {
|
||||
final value = board[idx];
|
||||
return InkWell(
|
||||
onTap: enabled && value == null ? () => onTapCell(idx) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Text(
|
||||
value ?? '',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user