// Created by ChatGPT 5 Thinking // Fixed by a mere human // Distributed under MIT License import 'dart:async'; import 'dart:math' as math; import 'dart:typed_data'; import 'dart:ui' as ui; // ignore: avoid_web_libraries_in_flutter import 'dart:html' as html; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; void main() { runApp(const TileAnnotatorApp()); } class TileAnnotatorApp extends StatelessWidget { const TileAnnotatorApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Tyler', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), useMaterial3: true, ), home: const AnnotatorHomePage(), debugShowCheckedModeBanner: false, ); } } class AnnotatorHomePage extends StatefulWidget { const AnnotatorHomePage({super.key}); @override State createState() => _AnnotatorHomePageState(); } class TileType { String tag; Color color; final Set tiles; TileType({required this.tag, required this.color, Set? tiles}) : tiles = tiles ?? {}; } class _AnnotatorHomePageState extends State { // Image/atlas state Uint8List? _atlasBytes; ui.Image? _atlasImage; // for intrinsic size int _tileSize = 32; // Types final List _types = []; // Editing state int? _editingTypeIndex; // null => not editing final Set _tempSelection = {}; // Viewer/interaction final TransformationController _transform = TransformationController(); bool _isMouseDown = false; bool _paintAdditive = true; // drag adds; click toggles // Gesture disambiguation state Offset? _pointerDownLocal; bool _dragExceededSlop = false; bool _transformChangedDuringDrag = false; bool _paintModeActive = false; int? _lastPaintedIdx; static const double _kTapSlop = 6.0; // Grid overlay option bool _showGrid = true; // Drag & drop visual hint bool _isDraggingOver = false; @override void initState() { super.initState(); if (kIsWeb) { _installGlobalDropHandlers(); } // Detect pan/zoom changes during a pointer-down gesture _transform.addListener(() { if (_isMouseDown) { _transformChangedDuringDrag = true; } }); } void _installGlobalDropHandlers() { // Prevent default navigation on drag-over/drop html.document.body?.addEventListener('dragover', (e) { e.preventDefault(); setState(() => _isDraggingOver = true); }); html.document.body?.addEventListener('dragleave', (e) { e.preventDefault(); setState(() => _isDraggingOver = false); }); html.document.body?.addEventListener('drop', (event) async { event.preventDefault(); setState(() => _isDraggingOver = false); if (event is html.MouseEvent) { final dt = event.dataTransfer; final files = dt.files; if (files == null || files.isEmpty) return; final file = files.first; if (!file.type.contains('image/png')) { _snack('Пожалуйста, перетащите PNG-файл.'); return; } final reader = html.FileReader(); final completer = Completer(); reader.onError.listen((_) { completer.completeError('Ошибка чтения файла'); }); reader.onLoadEnd.listen((_) { final result = reader.result; if (result is Uint8List) { completer.complete(result); } else if (result is ByteBuffer) { completer.complete(result.asUint8List()); } else if (result is String) { // DataURL final bytes = _dataUrlToBytes(result); completer.complete(bytes); } else { completer.completeError('Неподдерживаемый формат файла'); } }); reader.readAsArrayBuffer(file); try { final bytes = await completer.future; await _setAtlasBytes(bytes); } catch (e) { _snack('Не удалось загрузить изображение: $e'); } } }); } static Uint8List _dataUrlToBytes(String dataUrl) { final comma = dataUrl.indexOf(','); final base64Data = dataUrl.substring(comma + 1); return Uint8List.fromList(html.window.atob(base64Data).codeUnits); } Future _setAtlasBytes(Uint8List bytes) async { try { final img = await _decodeImage(bytes); setState(() { _atlasBytes = bytes; _atlasImage = img; _transform.value = Matrix4.identity(); _editingTypeIndex = null; _tempSelection.clear(); }); } catch (e) { _snack('Ошибка декодирования изображения: $e'); } } Future _decodeImage(Uint8List bytes) async { final completer = Completer(); ui.decodeImageFromList(bytes, (ui.Image img) { completer.complete(img); }); return completer.future; } void _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(msg)), ); } int get cols { if (_atlasImage == null || _tileSize <= 0) return 0; return (_atlasImage!.width / _tileSize).floor(); } int get rows { if (_atlasImage == null || _tileSize <= 0) return 0; return (_atlasImage!.height / _tileSize).floor(); } bool get _hasPerfectFit { if (_atlasImage == null || _tileSize <= 0) return false; return _atlasImage!.width % _tileSize == 0 && _atlasImage!.height % _tileSize == 0; } int? _pointToTileIndex(Offset localInViewer) { // Map from viewer-space to image-space using the inverse transform final scene = _transform.toScene(localInViewer); if (scene.dx < 0 || scene.dy < 0) return null; if (_atlasImage == null) return null; if (scene.dx >= _atlasImage!.width || scene.dy >= _atlasImage!.height) return null; if (_tileSize <= 0) return null; final c = scene.dx ~/ _tileSize; final r = scene.dy ~/ _tileSize; if (c < 0 || c >= cols || r < 0 || r >= rows) return null; return r * cols + c; } void _handlePointerDown(PointerDownEvent e) { if (_atlasImage == null) return; _isMouseDown = true; _pointerDownLocal = e.localPosition; _dragExceededSlop = false; _transformChangedDuringDrag = false; _paintModeActive = false; _lastPaintedIdx = null; // no selection on down; we decide tap vs drag on up } void _handlePointerMove(PointerMoveEvent e) { if (!_isMouseDown || _atlasImage == null) return; final start = _pointerDownLocal ?? e.localPosition; if ((e.localPosition - start).distance > _kTapSlop) { _dragExceededSlop = true; } // Only paint when editing AND not panning/zooming if (_editingTypeIndex == null || _transformChangedDuringDrag) return; final idx = _pointToTileIndex(e.localPosition); if (idx == null) return; if (!_paintModeActive) { _paintAdditive = !_tempSelection.contains(idx); // decide add/remove once _paintModeActive = true; } if (_lastPaintedIdx == idx) return; setState(() { if (_paintAdditive) { _tempSelection.add(idx); } else { _tempSelection.remove(idx); } }); _lastPaintedIdx = idx; } void _handlePointerUp(PointerUpEvent e) { _isMouseDown = false; // If user panned/zoomed during this gesture – do nothing if (_transformChangedDuringDrag) return; // If it was a drag (not a tap), painting already happened in move if (_dragExceededSlop) return; // True tap – toggle selection or show snackbar final idx = _pointToTileIndex(e.localPosition); if (idx == null) return; if (_editingTypeIndex == null) { _snack('Сначала добавьте или откройте тип тайлов для редактирования.'); return; } setState(() { if (_tempSelection.contains(idx)) { _tempSelection.remove(idx); } else { _tempSelection.add(idx); } }); } // Type management void _addTypeDialog() async { final controller = TextEditingController(); final tag = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Новый тип тайла'), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( labelText: 'Тег (например, road)', ), onSubmitted: (v) => Navigator.pop(context, v.trim()), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), FilledButton( onPressed: () => Navigator.pop(context, controller.text.trim()), child: const Text('Создать'), ), ], ); }, ); if (tag == null || tag.isEmpty) return; final color = _randomNiceColor(); setState(() { _types.add(TileType(tag: tag, color: color)); _editingTypeIndex = _types.length - 1; _tempSelection..clear(); }); } Color _randomNiceColor() { final rnd = math.Random(); final hue = rnd.nextDouble() * 360.0; final sat = 0.65; final val = 0.85; final color = _hsvToColor(hue, sat, val); return color.withOpacity(0.35); } Color _hsvToColor(double h, double s, double v) { final c = v * s; final x = c * (1 - ((h / 60) % 2 - 1).abs()); final m = v - c; double r = 0, g = 0, b = 0; if (h < 60) { r = c; g = x; b = 0; } else if (h < 120) { r = x; g = c; b = 0; } else if (h < 180) { r = 0; g = c; b = x; } else if (h < 240) { r = 0; g = x; b = c; } else if (h < 300) { r = x; g = 0; b = c; } else { r = c; g = 0; b = x; } return Color.fromARGB(255, ((r + m) * 255).round(), ((g + m) * 255).round(), ((b + m) * 255).round()); } void _enterEdit(int index) { setState(() { _editingTypeIndex = index; _tempSelection ..clear() ..addAll(_types[index].tiles); }); } void _confirmEdit() { if (_editingTypeIndex == null) return; setState(() { _types[_editingTypeIndex!].tiles ..clear() ..addAll(_tempSelection); _editingTypeIndex = null; _tempSelection.clear(); }); } void _cancelEdit() { setState(() { _editingTypeIndex = null; _tempSelection.clear(); }); } void _deleteType(int index) { setState(() { if (_editingTypeIndex == index) { _editingTypeIndex = null; _tempSelection.clear(); } _types.removeAt(index); }); } void _renameType(int index) async { final controller = TextEditingController(text: _types[index].tag); final newTag = await showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Переименовать тип'), content: TextField( controller: controller, autofocus: true, onSubmitted: (v) => Navigator.pop(context, v.trim()), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена')), FilledButton( onPressed: () => Navigator.pop(context, controller.text.trim()), child: const Text('OK')), ], ); }, ); if (newTag == null || newTag.isEmpty) return; setState(() => _types[index].tag = newTag); } // Export Lua void _exportLua() { if (_atlasImage == null) { _snack('Сначала загрузите атлас.'); return; } if (_tileSize <= 0) { _snack('Укажите корректный размер тайла.'); return; } final buf = StringBuffer(); buf.writeln('return {'); buf.writeln(' tileSize = $_tileSize,'); // Sort by tag for stable output final sorted = [..._types]; sorted.sort((a, b) => a.tag.compareTo(b.tag)); for (final t in sorted) { final key = _luaKey(t.tag); final indices = t.tiles.toList()..sort(); final line = indices.join(', '); buf.writeln(' $key = {$line},'); } buf.writeln('}'); final lua = buf.toString(); final bytes = Uint8List.fromList(lua.codeUnits); final blob = html.Blob([bytes], 'text/plain'); final url = html.Url.createObjectUrlFromBlob(blob); final anchor = html.AnchorElement(href: url) ..download = 'manifest.lua' ..style.display = 'none'; html.document.body!.append(anchor); anchor.click(); anchor.remove(); html.Url.revokeObjectUrl(url); } String _luaKey(String tag) { final idRe = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*\$'); // unchanged on purpose if (idRe.hasMatch(tag)) return tag; final escaped = tag .replaceAll('\\', r'\\') .replaceAll('"', r'\"') .replaceAll('\n', r'\n'); return '["$escaped"]'; } // File dialog Future _openFileDialog() async { final input = html.FileUploadInputElement(); input.accept = 'image/png'; input.click(); await input.onChange.first; final file = input.files?.first; if (file == null) return; if (!file.type.contains('image/png')) { _snack('Пожалуйста, выберите PNG-файл.'); return; } final reader = html.FileReader(); final c = Completer(); reader.onError.listen((_) => c.completeError('Ошибка чтения файла')); reader.onLoadEnd.listen((_) { final result = reader.result; if (result is Uint8List) { c.complete(result); } else if (result is ByteBuffer) { c.complete(result.asUint8List()); } else if (result is String) { c.complete(_dataUrlToBytes(result)); } else { c.completeError('Неподдерживаемый формат'); } }); reader.readAsArrayBuffer(file); try { final bytes = await c.future; await _setAtlasBytes(bytes); } catch (e) { _snack('Не удалось загрузить изображение: $e'); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Tyler'), actions: [ IconButton( tooltip: 'Экспорт в manifest.lua', icon: const Icon(Icons.download), onPressed: _exportLua, ), const SizedBox(width: 8), ], ), body: _atlasBytes == null ? _buildWelcome() : Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(child: _buildViewer()), SizedBox(width: 320, child: _buildRightPanel()), ], ), ); } Widget _buildWelcome() { return Stack( children: [ Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.image_outlined, size: 72, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 12), const Text('Перетащите PNG-атлас сюда', style: TextStyle(fontSize: 20)), const SizedBox(height: 8), const Text('или'), const SizedBox(height: 8), FilledButton.icon( onPressed: _openFileDialog, icon: const Icon(Icons.upload_file), label: const Text('Выбрать файл...'), ), const SizedBox(height: 24), _tileSizeField(), ], ), ), if (_isDraggingOver) Positioned.fill( child: IgnorePointer( child: Container( color: Colors.teal.withOpacity(0.12), child: const Center( child: Text('Отпустите, чтобы загрузить PNG', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), ), ), ), ), ], ); } Widget _tileSizeField({bool readOnly = false}) { final controller = TextEditingController(text: _tileSize.toString()); return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 240), child: TextField( controller: controller, keyboardType: TextInputType.number, readOnly: readOnly, decoration: InputDecoration( labelText: 'Размер тайла (px)', helperText: _atlasImage == null ? null : _hasPerfectFit ? 'Сетка делит атлас на ${rows}×${cols} тайлов' : '⚠️ Размер не делит атлас без остатка (${_atlasImage!.width}×${_atlasImage!.height})', ), onChanged: (v) { _tileSize = int.tryParse(v) ?? 0; }, ), ); } Widget _buildViewer() { if (_atlasBytes == null || _atlasImage == null) { return const SizedBox.shrink(); } final w = _atlasImage!.width.toDouble(); final h = _atlasImage!.height.toDouble(); return Stack( children: [ ClipRect( // (1) clip atlas to viewport child: Listener( onPointerDown: _handlePointerDown, onPointerMove: _handlePointerMove, onPointerUp: _handlePointerUp, child: InteractiveViewer( constrained: false, transformationController: _transform, minScale: 0.2, maxScale: 50.0, boundaryMargin: const EdgeInsets.all(double.infinity), child: SizedBox( width: w, height: h, child: Stack( children: [ // Crisp nearest-neighbor display Positioned.fill( child: Image.memory( _atlasBytes!, isAntiAlias: false, filterQuality: FilterQuality.none, fit: BoxFit.fill, ), ), // Overlay: grid & colored regions Positioned.fill( child: CustomPaint( painter: _OverlayPainter( cols: cols, rows: rows, tileSize: _tileSize, showGrid: _showGrid, types: _types, editingTypeIndex: _editingTypeIndex, tempSelection: _tempSelection, ), ), ), ], ), ), ), ), ), Positioned( left: 12, bottom: 12, child: DecoratedBox( decoration: BoxDecoration( color: Colors.black.withOpacity(0.5), borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.zoom_in_map, size: 16, color: Colors.white), const SizedBox(width: 8), Text( '${_atlasImage!.width}×${_atlasImage!.height} • ${rows}×${cols} tiles', style: const TextStyle(color: Colors.white)), ], ), ), ), ), ], ); } Widget _buildRightPanel() { return Material( color: Theme.of(context).colorScheme.surfaceContainerLowest, child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), child: Row( children: [ Expanded(child: _tileSizeField(readOnly: true)), const SizedBox(width: 8), Tooltip( message: _showGrid ? 'Скрыть сетку' : 'Показать сетку', child: IconButton( isSelected: _showGrid, onPressed: () => setState(() => _showGrid = !_showGrid), icon: const Icon(Icons.grid_4x4_outlined), ), ), ], ), ), const Divider(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ const Text('Типы тайлов', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Spacer(), IconButton( tooltip: 'Добавить тип', onPressed: _atlasImage == null ? null : _addTypeDialog, icon: const Icon(Icons.add), ), ], ), ), const SizedBox(height: 8), Expanded( child: _types.isEmpty ? const Center( child: Text('Типы не заданы'), ) : ListView.separated( itemCount: _types.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final t = _types[index]; final selected = _editingTypeIndex == index; return ListTile( selected: selected, leading: Container( width: 24, height: 24, decoration: BoxDecoration( color: t.color, border: Border.all(color: Colors.black26), borderRadius: BorderRadius.circular(4), ), ), title: Text(t.tag), subtitle: Text('${t.tiles.length} тайлов'), onTap: () => _enterEdit(index), trailing: Wrap( spacing: 4, children: [ IconButton( tooltip: 'Переименовать', icon: const Icon(Icons.edit_outlined), onPressed: () => _renameType(index), ), IconButton( tooltip: 'Удалить', icon: const Icon(Icons.delete_outline), onPressed: () => _deleteType(index), ), ], ), ); }, ), ), if (_editingTypeIndex != null) Container( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer, border: const Border( top: BorderSide(width: 1, color: Colors.black12)), ), child: Row( children: [ Expanded( child: Text( 'Редактирование: ${_types[_editingTypeIndex!].tag} — выбрано ${_tempSelection.length}', overflow: TextOverflow.ellipsis, ), ), TextButton( onPressed: _cancelEdit, child: const Text('Отмена')), const SizedBox(width: 8), FilledButton( onPressed: _confirmEdit, child: const Text('Подтвердить')), ], ), ), ], ), ); } } class _OverlayPainter extends CustomPainter { final int cols; final int rows; final int tileSize; final bool showGrid; final List types; final int? editingTypeIndex; final Set tempSelection; _OverlayPainter({ required this.cols, required this.rows, required this.tileSize, required this.showGrid, required this.types, required this.editingTypeIndex, required this.tempSelection, }); @override void paint(Canvas canvas, Size size) { // Draw selected tiles for all types for (var i = 0; i < types.length; i++) { final t = types[i]; final color = (i == editingTypeIndex) ? t.color.withOpacity(math.min(t.color.opacity + 0.15, 0.8)) : t.color; final paint = Paint()..color = color; for (final idx in t.tiles) { final r = idx ~/ cols; final c = idx % cols; final rect = Rect.fromLTWH(c * tileSize.toDouble(), r * tileSize.toDouble(), tileSize.toDouble(), tileSize.toDouble()); canvas.drawRect(rect, paint); } } // Draw temp selection (while editing) if (editingTypeIndex != null) { final tempPaint = Paint() ..color = Colors.white.withOpacity(0.25) ..style = PaintingStyle.fill; final border = Paint() ..color = Colors.white ..style = PaintingStyle.stroke ..strokeWidth = 1.0; for (final idx in tempSelection) { final r = idx ~/ cols; final c = idx % cols; final rect = Rect.fromLTWH(c * tileSize.toDouble(), r * tileSize.toDouble(), tileSize.toDouble(), tileSize.toDouble()); canvas.drawRect(rect, tempPaint); canvas.drawRect(rect, border); } } if (showGrid) { final gridPaint = Paint() ..color = Colors.black.withOpacity(0.2) ..style = PaintingStyle.stroke ..strokeWidth = 1.0; for (int c = 0; c <= cols; c++) { final x = c * tileSize.toDouble(); canvas.drawLine( Offset(x, 0), Offset(x, rows * tileSize.toDouble()), gridPaint); } for (int r = 0; r <= rows; r++) { final y = r * tileSize.toDouble(); canvas.drawLine( Offset(0, y), Offset(cols * tileSize.toDouble(), y), gridPaint); } } } @override bool shouldRepaint(covariant _OverlayPainter oldDelegate) { return cols != oldDelegate.cols || rows != oldDelegate.rows || tileSize != oldDelegate.tileSize || showGrid != oldDelegate.showGrid || !setEquals(tempSelection, oldDelegate.tempSelection) || types.length != oldDelegate.types.length || editingTypeIndex != oldDelegate.editingTypeIndex || !_typesEqual(types, oldDelegate.types); } bool _typesEqual(List a, List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i].tag != b[i].tag) return false; if (a[i].color != b[i].color) return false; if (!setEquals(a[i].tiles, b[i].tiles)) return false; } return true; } }