tyler/lib/main.dart
2025-08-15 01:23:05 +03:00

893 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}
}