893 lines
28 KiB
Dart
893 lines
28 KiB
Dart
// 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<AnnotatorHomePage> createState() => _AnnotatorHomePageState();
|
||
}
|
||
|
||
class TileType {
|
||
String tag;
|
||
Color color;
|
||
final Set<int> tiles;
|
||
TileType({required this.tag, required this.color, Set<int>? tiles})
|
||
: tiles = tiles ?? <int>{};
|
||
}
|
||
|
||
class _AnnotatorHomePageState extends State<AnnotatorHomePage> {
|
||
// Image/atlas state
|
||
Uint8List? _atlasBytes;
|
||
ui.Image? _atlasImage; // for intrinsic size
|
||
int _tileSize = 32;
|
||
|
||
// Types
|
||
final List<TileType> _types = [];
|
||
|
||
// Editing state
|
||
int? _editingTypeIndex; // null => not editing
|
||
final Set<int> _tempSelection = <int>{};
|
||
|
||
// 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;
|
||
if (dt == null) return;
|
||
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<Uint8List>();
|
||
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<void> _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<ui.Image> _decodeImage(Uint8List bytes) async {
|
||
final completer = Completer<ui.Image>();
|
||
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<String>(
|
||
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<String>(
|
||
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<void> _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<Uint8List>();
|
||
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})',
|
||
),
|
||
onSubmitted: (v) {
|
||
// Size locked during runtime: keep helper text refresh only
|
||
setState(() {});
|
||
// setState(() => _tileSize = n); // disabled
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
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<TileType> types;
|
||
final int? editingTypeIndex;
|
||
final Set<int> 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<TileType> a, List<TileType> 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;
|
||
}
|
||
}
|