pyscreeps-arena 0.5.7b0__py3-none-any.whl → 0.5.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,728 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ QPSA Map Viewer - 地图查看器组件
4
+ """
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from typing import Optional, List, Dict, Any
9
+ from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel,
10
+ QPushButton, QFileDialog, QGraphicsView,
11
+ QGraphicsScene, QGraphicsPixmapItem, QFrame)
12
+ from PyQt6.QtCore import Qt, pyqtSignal, QPointF, QRectF, QMimeData
13
+ from PyQt6.QtGui import QPixmap, QImage, QPainter, QPen, QColor, QBrush, QDrag
14
+ from PIL import Image, ImageDraw
15
+ import math
16
+
17
+ # Import configuration from build.py
18
+ import sys
19
+ import os
20
+ # Add the project root directory to Python path
21
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
22
+ from pyscreeps_arena import config
23
+
24
+ # Language mapping
25
+ LANG = {
26
+ 'cn': {
27
+ 'no_map_preview': '没有地图可预览,请选择',
28
+ 'open_map': '打开地图',
29
+ 'map_file_filter': 'JSON文件 (*.json)',
30
+ 'all_files': '所有文件 (*)',
31
+ 'error': '错误',
32
+ 'invalid_json': '无效的JSON文件',
33
+ 'load_error': '加载地图失败',
34
+ 'file_not_found': '文件未找到',
35
+ 'invalid_map_data': '无效的地图数据',
36
+ },
37
+ 'en': {
38
+ 'no_map_preview': 'No map to preview, please select',
39
+ 'open_map': 'Open Map',
40
+ 'map_file_filter': 'JSON Files (*.json)',
41
+ 'all_files': 'All Files (*)',
42
+ 'error': 'Error',
43
+ 'invalid_json': 'Invalid JSON file',
44
+ 'load_error': 'Failed to load map',
45
+ 'file_not_found': 'File not found',
46
+ 'invalid_map_data': 'Invalid map data',
47
+ }
48
+ }
49
+
50
+ def lang(key: str) -> str:
51
+ """Helper function to get translated text"""
52
+ return LANG[config.language if hasattr(config, 'language') and config.language in LANG else 'cn'][key]
53
+
54
+
55
+ class CellInfo:
56
+ """Cell information container."""
57
+
58
+ def __init__(self, x: int, y: int):
59
+ self._x = x
60
+ self._y = y
61
+ self._objects: List[Dict[str, Any]] = []
62
+ self._terrain: str = '2' # Default to plain terrain
63
+
64
+ @property
65
+ def x(self) -> int:
66
+ return self._x
67
+
68
+ @property
69
+ def y(self) -> int:
70
+ return self._y
71
+
72
+ @property
73
+ def objects(self) -> List[Dict[str, Any]]:
74
+ return self._objects.copy()
75
+
76
+ @property
77
+ def terrain(self) -> str:
78
+ return self._terrain
79
+
80
+ @terrain.setter
81
+ def terrain(self, value: str):
82
+ self._terrain = value
83
+
84
+ @property
85
+ def cost(self) -> int:
86
+ """
87
+ Get movement cost for this cell.
88
+
89
+ :return: int Movement cost (1=plain+road, 2=plain/swamp+road, 10=swamp, 255=wall)
90
+ """
91
+ # Check if there's a road in this cell
92
+ has_road = any(obj.get('type') == 'StructureRoad' for obj in self._objects)
93
+
94
+ # Base terrain cost
95
+ if self._terrain == '2': # plain
96
+ base_cost = 2
97
+ elif self._terrain == 'A': # swamp
98
+ base_cost = 10
99
+ elif self._terrain == 'X': # wall
100
+ base_cost = 255
101
+ else:
102
+ base_cost = 2 # Default to plain cost
103
+
104
+ # Apply road reduction
105
+ if has_road:
106
+ if self._terrain == '2': # plain + road
107
+ return 1
108
+ elif self._terrain == 'A': # swamp + road
109
+ return 2
110
+
111
+ return base_cost
112
+
113
+ def add_object(self, obj: Dict[str, Any]):
114
+ """Add an object to this cell."""
115
+ self._objects.append(obj)
116
+
117
+ def clear_objects(self):
118
+ """Clear all objects from this cell."""
119
+ self._objects.clear()
120
+
121
+ def __repr__(self):
122
+ return f"CellInfo(x={self._x}, y={self._y}, objects={len(self._objects)})"
123
+
124
+
125
+ class QPSAMapViewer(QWidget):
126
+ """PyScreeps Arena Map Viewer component."""
127
+
128
+ # Signals
129
+ currentChanged = pyqtSignal(object) # Current cell under mouse changed (CellInfo or None)
130
+ selectChanged = pyqtSignal(object) # Selected cell changed (CellInfo or None)
131
+ rightClicked = pyqtSignal(object) # Right-clicked cell (CellInfo or None)
132
+
133
+ def __init__(self, parent=None):
134
+ super().__init__(parent)
135
+ self._map_data: Optional[Dict[str, Any]] = None
136
+ self._map_path: Optional[str] = None
137
+ self._cell_size = 64 # Default cell size changed to 32
138
+ self._current_cell: Optional[CellInfo] = None
139
+ self._selected_cell: Optional[CellInfo] = None
140
+ self._map_width = 0
141
+ self._map_height = 0
142
+ self._cell_info_grid: List[List[CellInfo]] = []
143
+ self._temp_image_path: Optional[str] = None
144
+
145
+ # Drag functionality
146
+ self._drag_start_pos = None
147
+ self._drag_cell_info = None
148
+ self._original_drag_mode = None
149
+ self._is_dragging = False
150
+
151
+ # Highlight colors
152
+ self._hover_color = QColor(240, 248, 255, 64) # #F0F8FF 25% transparent
153
+ self._selection_width = 2
154
+
155
+ self._init_ui()
156
+ self._init_scene()
157
+
158
+ def _init_ui(self):
159
+ """Initialize the UI components."""
160
+ # Set minimum size to 1024x1024 and allow expansion
161
+ self.setMinimumSize(1024, 1024)
162
+ self.setSizePolicy(
163
+ self.sizePolicy().Policy.Expanding,
164
+ self.sizePolicy().Policy.Expanding
165
+ )
166
+
167
+ # Main layout
168
+ layout = QVBoxLayout()
169
+ layout.setContentsMargins(10, 10, 10, 10)
170
+
171
+ # Status label
172
+ self._status_label = QLabel(lang('no_map_preview'))
173
+ self._status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
174
+ self._status_label.setStyleSheet("font-size: 14px; color: #666;")
175
+ layout.addWidget(self._status_label)
176
+
177
+ # Graphics view for map display
178
+ self._graphics_view = QGraphicsView()
179
+ self._graphics_view.setFrameShape(QFrame.Shape.NoFrame)
180
+ self._graphics_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
181
+ self._graphics_view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
182
+ self._graphics_view.setRenderHint(QPainter.RenderHint.Antialiasing)
183
+ self._graphics_view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
184
+ self._graphics_view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
185
+ layout.addWidget(self._graphics_view)
186
+
187
+ # Button layout
188
+ button_layout = QHBoxLayout()
189
+ button_layout.addStretch()
190
+
191
+ self._open_button = QPushButton(lang('open_map'))
192
+ self._open_button.setFixedSize(120, 40)
193
+ self._open_button.clicked.connect(self._open_map_file)
194
+ button_layout.addWidget(self._open_button)
195
+
196
+ button_layout.addStretch()
197
+ layout.addLayout(button_layout)
198
+
199
+ self.setLayout(layout)
200
+
201
+ # Initially hide graphics view until map is loaded
202
+ self._graphics_view.hide()
203
+
204
+ def _init_scene(self):
205
+ """Initialize the graphics scene."""
206
+ self._scene = QGraphicsScene()
207
+ self._graphics_view.setScene(self._scene)
208
+ self._map_item: Optional[QGraphicsPixmapItem] = None
209
+ self._highlight_item: Optional[QGraphicsPixmapItem] = None
210
+ self._selection_item: Optional[QGraphicsPixmapItem] = None
211
+
212
+ def _open_map_file(self):
213
+ """Open and load a map JSON file."""
214
+ # Set default directory to desktop
215
+ import os
216
+ desktop_path = os.path.join(os.path.expanduser('~'), 'Desktop')
217
+
218
+ file_path, _ = QFileDialog.getOpenFileName(
219
+ self,
220
+ "选择地图文件",
221
+ desktop_path,
222
+ f"{lang('map_file_filter')};;{lang('all_files')}"
223
+ )
224
+
225
+ if file_path:
226
+ # Change button text to "render..." while rendering
227
+ self._open_button.setText("render...")
228
+ # Force UI update
229
+ from PyQt6.QtWidgets import QApplication
230
+ QApplication.processEvents()
231
+
232
+ try:
233
+ self.load_map(file_path)
234
+ except Exception as e:
235
+ self._show_error(f"{lang('load_error')}: {str(e)}")
236
+ # Restore original button text on error
237
+ self._open_button.setText(lang('open_map'))
238
+
239
+ def load_map(self, file_path: str):
240
+ """
241
+ Load a map from JSON file.
242
+
243
+ :param file_path: Path to the map JSON file
244
+ :raises: ValueError if the file format is invalid
245
+ """
246
+ if not os.path.exists(file_path):
247
+ raise FileNotFoundError(f"{lang('file_not_found')}: {file_path}")
248
+
249
+ try:
250
+ with open(file_path, 'r', encoding='utf-8') as f:
251
+ map_data = json.load(f)
252
+ except json.JSONDecodeError as e:
253
+ raise ValueError(f"{lang('invalid_json')}: {e}")
254
+
255
+ # Validate map format
256
+ if 'map' not in map_data or not isinstance(map_data['map'], list):
257
+ raise ValueError(lang('invalid_map_data'))
258
+
259
+ if not map_data['map']:
260
+ raise ValueError(lang('invalid_map_data'))
261
+
262
+ # Validate that all rows have the same length
263
+ row_length = len(map_data['map'][0])
264
+ for i, row in enumerate(map_data['map']):
265
+ if len(row) != row_length:
266
+ raise ValueError(f"{lang('invalid_map_data')}: row {i+1}")
267
+
268
+ self._map_data = map_data
269
+ self._map_path = file_path
270
+ self._map_height = len(map_data['map'])
271
+ self._map_width = row_length
272
+
273
+ # Build cell info grid
274
+ self._build_cell_info_grid()
275
+
276
+ # Render the map
277
+ self._render_map()
278
+
279
+ # Update UI
280
+ self._update_ui_after_load()
281
+
282
+ print(f"[DEBUG] Map loaded: {self._map_width}x{self._map_height}") # 调试输出
283
+
284
+ def _build_cell_info_grid(self):
285
+ """Build cell information grid from map data."""
286
+ self._cell_info_grid = []
287
+
288
+ # Get terrain data from map
289
+ terrain_map = self._map_data.get('map', [])
290
+
291
+ for y in range(self._map_height):
292
+ row = []
293
+ for x in range(self._map_width):
294
+ cell_info = CellInfo(x, y)
295
+
296
+ # Set terrain if available
297
+ if y < len(terrain_map) and x < len(terrain_map[y]):
298
+ cell_info.terrain = terrain_map[y][x]
299
+
300
+ row.append(cell_info)
301
+ self._cell_info_grid.append(row)
302
+
303
+ # Populate objects from map data
304
+ if 'objects' in self._map_data:
305
+ for obj_type, obj_list in self._map_data['objects'].items():
306
+ # Skip GameObject and Structure base types, but keep their subclasses
307
+ if obj_type in ['GameObject', 'Structure']:
308
+ print(f"[DEBUG] Skipping base type: {obj_type}") # 调试输出
309
+ continue
310
+
311
+ for obj in obj_list:
312
+ x, y = obj['x'], obj['y']
313
+ if 0 <= x < self._map_width and 0 <= y < self._map_height:
314
+ obj_copy = obj.copy()
315
+ obj_copy['type'] = obj_type
316
+ self._cell_info_grid[y][x].add_object(obj_copy)
317
+
318
+ def _render_map(self):
319
+ """Render the map using the existing map_render functionality."""
320
+ try:
321
+ # Import the renderer from the existing module
322
+ from ..map_render import MapRender
323
+
324
+ # Create a temporary file for the rendered image
325
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
326
+ self._temp_image_path = tmp.name
327
+
328
+ # Render the map
329
+ renderer = MapRender(self._map_path, self._cell_size)
330
+ renderer.render(self._temp_image_path, show_grid=True)
331
+
332
+ # Load the rendered image
333
+ pixmap = QPixmap(self._temp_image_path)
334
+
335
+ # Clear existing items
336
+ self._scene.clear()
337
+ self._map_item = None
338
+ self._highlight_item = None
339
+ self._selection_item = None
340
+
341
+ # Add map to scene
342
+ self._map_item = self._scene.addPixmap(pixmap)
343
+
344
+ # Set scene rect to match image size
345
+ self._scene.setSceneRect(QRectF(pixmap.rect()))
346
+
347
+ # Fit the view to the image
348
+ self._graphics_view.fitInView(self._scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
349
+
350
+ # Set default zoom level to 2.0
351
+ self._graphics_view.scale(2.0, 2.0)
352
+
353
+ print(f"[DEBUG] Map rendered to temporary file: {self._temp_image_path}") # 调试输出
354
+
355
+ except Exception as e:
356
+ raise RuntimeError(f"地图渲染失败: {e}")
357
+
358
+ def _update_ui_after_load(self):
359
+ """Update UI after successful map load."""
360
+ self._status_label.setText(f"地图已加载: {self._map_width}x{self._map_height}")
361
+ self._status_label.hide()
362
+ self._graphics_view.show()
363
+
364
+ # Restore original button text after successful loading
365
+ self._open_button.setText(lang('open_map'))
366
+
367
+ # Enable mouse tracking for hover effects
368
+ self._graphics_view.setMouseTracking(True)
369
+ self._graphics_view.viewport().installEventFilter(self)
370
+
371
+ # Store current map path for language switching
372
+ self._current_map_path = self._map_path
373
+
374
+ # Enable key event handling
375
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
376
+
377
+ # Enable drag and drop for the graphics view viewport
378
+ self._graphics_view.viewport().setAcceptDrops(True)
379
+
380
+ def dragEnterEvent(self, event):
381
+ """Accept drag enter events."""
382
+ print(f"[DEBUG] Drag enter event received")
383
+ event.acceptProposedAction()
384
+
385
+ def dragMoveEvent(self, event):
386
+ """Accept drag move events."""
387
+ event.acceptProposedAction()
388
+
389
+ def dropEvent(self, event):
390
+ """Handle drop events."""
391
+ print(f"[DEBUG] Drop event received")
392
+ event.acceptProposedAction()
393
+
394
+ def keyPressEvent(self, event):
395
+ """Handle key press events for Ctrl/Alt drag functionality."""
396
+ if event.key() in (Qt.Key.Key_Control, Qt.Key.Key_Alt):
397
+ if self._original_drag_mode is None:
398
+ # Store original drag mode and disable it
399
+ self._original_drag_mode = self._graphics_view.dragMode()
400
+ self._graphics_view.setDragMode(QGraphicsView.DragMode.NoDrag)
401
+ print(f"[DEBUG] Ctrl/Alt pressed: disabled graphics view drag mode (was: {self._original_drag_mode})")
402
+ super().keyPressEvent(event)
403
+
404
+ def keyReleaseEvent(self, event):
405
+ """Handle key release events to restore drag functionality."""
406
+ if event.key() in (Qt.Key.Key_Control, Qt.Key.Key_Alt):
407
+ # Check if both Ctrl and Alt are released
408
+ modifiers = event.modifiers()
409
+ if not (modifiers & Qt.KeyboardModifier.ControlModifier) and not (modifiers & Qt.KeyboardModifier.AltModifier):
410
+ if self._original_drag_mode is not None:
411
+ # Restore original drag mode
412
+ self._graphics_view.setDragMode(self._original_drag_mode)
413
+ print(f"[DEBUG] Ctrl/Alt released: restored graphics view drag mode to: {self._original_drag_mode}")
414
+ self._original_drag_mode = None
415
+ super().keyReleaseEvent(event)
416
+
417
+ def _show_error(self, message: str):
418
+ """Show error message."""
419
+ from PyQt6.QtWidgets import QMessageBox
420
+ QMessageBox.critical(self, lang('error'), message)
421
+
422
+ def _reset_to_default_state(self):
423
+ """Reset to default state with language-appropriate text."""
424
+ self._status_label.setText(lang('no_map_preview'))
425
+ self._status_label.show()
426
+ self._graphics_view.hide()
427
+ self._open_button.setText(lang('open_map'))
428
+
429
+ def eventFilter(self, obj, event):
430
+ """Handle mouse events for the graphics view."""
431
+ if obj == self._graphics_view.viewport() and self._map_item is not None:
432
+ if event.type() == event.Type.MouseMove:
433
+ self._handle_mouse_move(event.pos())
434
+ # Handle drag initiation during mouse move
435
+ if self._is_dragging and self._drag_start_pos is not None:
436
+ # Calculate drag distance to determine if it's a valid drag
437
+ drag_distance = abs(event.pos().x() - self._drag_start_pos.x()) + abs(event.pos().y() - self._drag_start_pos.y())
438
+ print(f"[DEBUG] Mouse move - drag distance: {drag_distance}, is_dragging: {self._is_dragging}")
439
+ if drag_distance > 10: # Minimum drag distance
440
+ print(f"[DEBUG] Starting drag operation")
441
+ # Use QApplication to start the drag from the viewport
442
+ from PyQt6.QtWidgets import QApplication
443
+ # Force the viewport to have focus for drag operations
444
+ self._graphics_view.viewport().setFocus()
445
+ self._initiate_drag(self._drag_cell_info)
446
+ # Reset drag state after initiating drag
447
+ self._drag_start_pos = None
448
+ self._drag_cell_info = None
449
+ self._is_dragging = False
450
+ elif event.type() == event.Type.MouseButtonPress:
451
+ # Ensure cursor remains cross during and after click
452
+ self._graphics_view.viewport().setCursor(Qt.CursorShape.CrossCursor)
453
+ if event.button() == Qt.MouseButton.LeftButton:
454
+ # Check if Ctrl or Alt is pressed for drag functionality
455
+ modifiers = event.modifiers()
456
+ print(f"[DEBUG] Mouse press - modifiers: {modifiers}")
457
+ if modifiers & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier):
458
+ # Store drag info for potential drag
459
+ cell_info = self._get_cell_at_pos(event.pos())
460
+ if cell_info is not None:
461
+ self._drag_start_pos = event.pos()
462
+ self._drag_cell_info = cell_info
463
+ self._is_dragging = True
464
+ print(f"[DEBUG] Drag preparation: cell ({cell_info.x}, {cell_info.y})")
465
+ else:
466
+ self._handle_mouse_click(event.pos())
467
+ elif event.button() == Qt.MouseButton.RightButton:
468
+ self._handle_right_click(event.pos())
469
+ elif event.type() == event.Type.MouseButtonRelease:
470
+ if event.button() == Qt.MouseButton.LeftButton:
471
+ # Reset drag state on mouse release
472
+ self._drag_start_pos = None
473
+ self._drag_cell_info = None
474
+ self._is_dragging = False
475
+ elif event.type() == event.Type.Wheel:
476
+ self._handle_wheel_event(event)
477
+ return True # Consume the event
478
+ elif event.type() == event.Type.Enter:
479
+ self._graphics_view.viewport().setCursor(Qt.CursorShape.CrossCursor)
480
+ elif event.type() == event.Type.Leave:
481
+ self._graphics_view.viewport().unsetCursor()
482
+
483
+ return super().eventFilter(obj, event)
484
+
485
+ def _handle_mouse_move(self, pos):
486
+ """Handle mouse move events."""
487
+ cell_info = self._get_cell_at_pos(pos)
488
+
489
+ if cell_info != self._current_cell:
490
+ self._current_cell = cell_info
491
+ self._update_highlight()
492
+ self.currentChanged.emit(cell_info)
493
+
494
+ def _handle_mouse_click(self, pos):
495
+ """Handle mouse click events."""
496
+ cell_info = self._get_cell_at_pos(pos)
497
+
498
+ if cell_info != self._selected_cell:
499
+ self._selected_cell = cell_info
500
+ self._update_selection()
501
+ self.selectChanged.emit(cell_info)
502
+
503
+ def _handle_right_click(self, pos):
504
+ """Handle right-click events."""
505
+ cell_info = self._get_cell_at_pos(pos)
506
+ self.rightClicked.emit(cell_info)
507
+
508
+ def _initiate_drag(self, cell_info: CellInfo):
509
+ """Initiate drag operation with cell data."""
510
+ print(f"[DEBUG] Initiating drag for cell ({cell_info.x}, {cell_info.y})")
511
+
512
+ # Generate drag data based on cell contents
513
+ if cell_info.objects:
514
+ # If cell has objects, create array of all objects with screenshots
515
+ drag_data = []
516
+
517
+ # Extract cell screenshot if map image is available
518
+ cell_image = None
519
+ if self.image is not None and not self.image.isNull():
520
+ try:
521
+ # Calculate cell size based on map image dimensions
522
+ cell_width = self.image.width() // self._map_width
523
+ cell_height = self.image.height() // self._map_height
524
+
525
+ # Calculate the position of this cell in the image
526
+ cell_x_offset = cell_info.x * cell_width
527
+ cell_y_offset = cell_info.y * cell_height
528
+
529
+ # Extract the cell region
530
+ if (cell_x_offset + cell_width <= self.image.width() and
531
+ cell_y_offset + cell_height <= self.image.height() and
532
+ cell_x_offset >= 0 and cell_y_offset >= 0):
533
+
534
+ cell_image = self.image.copy(cell_x_offset, cell_y_offset, cell_width, cell_height)
535
+ print(f"[DEBUG] Extracted cell screenshot for ({cell_info.x}, {cell_info.y}): {cell_image.size()}")
536
+ except Exception as e:
537
+ print(f"[DEBUG] Failed to extract cell screenshot: {e}")
538
+
539
+ # Create object data for each object with the same screenshot
540
+ for obj in cell_info.objects:
541
+ obj_data = {
542
+ "x": cell_info.x,
543
+ "y": cell_info.y,
544
+ "type": obj.get('type', 'Unknown'),
545
+ "method": obj.get('method', ''),
546
+ "id": obj.get('id', 'unknown'),
547
+ "name": obj.get('name', obj.get('id', 'unknown'))
548
+ }
549
+ drag_data.append(obj_data)
550
+ else:
551
+ # If cell has no objects, create point data (no screenshot)
552
+ drag_data = {
553
+ "x": cell_info.x,
554
+ "y": cell_info.y,
555
+ "type": "Point",
556
+ "method": "Point",
557
+ "id": f"{cell_info.x}◇{cell_info.y}",
558
+ "name": f"p{cell_info.x}◇{cell_info.y}"
559
+ }
560
+
561
+ # Convert to JSON string
562
+ json_data = json.dumps(drag_data, ensure_ascii=False, indent=2)
563
+ print(f"[DEBUG] Drag data: {json_data}")
564
+
565
+ # Create mime data
566
+ mime_data = QMimeData()
567
+ mime_data.setText(json_data)
568
+ mime_data.setData("application/json", json_data.encode('utf-8'))
569
+
570
+ # Add cell screenshot if available
571
+ if cell_info.objects and cell_image is not None and not cell_image.isNull():
572
+ mime_data.setImageData(cell_image)
573
+ print(f"[DEBUG] Added cell screenshot to drag data")
574
+
575
+ # Create drag object
576
+ drag = QDrag(self._graphics_view.viewport())
577
+ drag.setMimeData(mime_data)
578
+
579
+ # Start drag operation
580
+ result = drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction)
581
+ print(f"[DEBUG] Drag result: {result}")
582
+
583
+ return result
584
+
585
+ def _handle_wheel_event(self, event):
586
+ """Handle mouse wheel events for zooming."""
587
+ if self._map_item is None:
588
+ return
589
+
590
+ # Get the wheel delta
591
+ delta = event.angleDelta().y()
592
+
593
+ # Calculate zoom factor
594
+ zoom_factor = 1.1 if delta > 0 else 0.9
595
+
596
+ # Limit zoom range
597
+ current_scale = self._graphics_view.transform().m11()
598
+ if (current_scale > 5.0 and zoom_factor > 1) or (current_scale < 0.1 and zoom_factor < 1):
599
+ return
600
+
601
+ # Get the position before scaling
602
+ old_pos = self._graphics_view.mapToScene(event.position().toPoint())
603
+
604
+ # Apply scaling
605
+ self._graphics_view.scale(zoom_factor, zoom_factor)
606
+
607
+ # Get the position after scaling
608
+ new_pos = self._graphics_view.mapToScene(event.position().toPoint())
609
+
610
+ # Adjust the scroll position to keep the mouse position fixed
611
+ delta_pos = new_pos - old_pos
612
+ self._graphics_view.translate(delta_pos.x(), delta_pos.y())
613
+
614
+ print(f"[DEBUG] Zoom: {current_scale:.2f} -> {self._graphics_view.transform().m11():.2f}") # 调试输出
615
+
616
+ def _get_cell_at_pos(self, pos) -> Optional[CellInfo]:
617
+ """Get cell information at the given viewport position."""
618
+ if self._map_item is None:
619
+ return None
620
+
621
+ # Map viewport position to scene coordinates
622
+ scene_pos = self._graphics_view.mapToScene(pos)
623
+
624
+ # Convert scene coordinates to cell coordinates
625
+ x = int(scene_pos.x() / self._cell_size)
626
+ y = int(scene_pos.y() / self._cell_size)
627
+
628
+ # Check bounds
629
+ if 0 <= x < self._map_width and 0 <= y < self._map_height:
630
+ return self._cell_info_grid[y][x]
631
+
632
+ return None
633
+
634
+ def _update_highlight(self):
635
+ """Update hover highlight."""
636
+ # Remove existing highlight
637
+ if self._highlight_item is not None:
638
+ self._scene.removeItem(self._highlight_item)
639
+ self._highlight_item = None
640
+
641
+ # Add new highlight if there's a current cell
642
+ if self._current_cell is not None:
643
+ rect = self._get_cell_rect(self._current_cell.x, self._current_cell.y)
644
+ self._highlight_item = self._scene.addRect(rect, QPen(Qt.PenStyle.NoPen), QBrush(self._hover_color))
645
+ self._highlight_item.setZValue(10) # Above map, below selection
646
+
647
+ def _update_selection(self):
648
+ """Update cell selection."""
649
+ # Remove existing selection
650
+ if self._selection_item is not None:
651
+ self._scene.removeItem(self._selection_item)
652
+ self._selection_item = None
653
+
654
+ # Add new selection if there's a selected cell
655
+ if self._selected_cell is not None:
656
+ rect = self._get_cell_rect(self._selected_cell.x, self._selected_cell.y)
657
+
658
+ # Calculate contrasting color for the border
659
+ border_color = self._get_contrasting_color()
660
+ pen = QPen(border_color, self._selection_width)
661
+ pen.setStyle(Qt.PenStyle.SolidLine)
662
+
663
+ self._selection_item = self._scene.addRect(rect, pen)
664
+ self._selection_item.setZValue(20) # Above everything
665
+
666
+ def _get_cell_rect(self, x: int, y: int) -> QRectF:
667
+ """Get rectangle for a cell."""
668
+ left = x * self._cell_size
669
+ top = y * self._cell_size
670
+ return QRectF(left, top, self._cell_size, self._cell_size)
671
+
672
+ def _get_contrasting_color(self) -> QColor:
673
+ """Get a contrasting color for selection border."""
674
+ # Use a bright color that contrasts with most backgrounds
675
+ return QColor(255, 0, 255) # Magenta
676
+
677
+ # Public properties
678
+ @property
679
+ def current(self) -> Optional[CellInfo]:
680
+ """Get current cell under mouse cursor."""
681
+ return self._current_cell
682
+
683
+ @property
684
+ def selected(self) -> Optional[CellInfo]:
685
+ """Get selected cell."""
686
+ return self._selected_cell
687
+
688
+ @property
689
+ def map_width(self) -> int:
690
+ """Get map width."""
691
+ return self._map_width
692
+
693
+ @property
694
+ def map_height(self) -> int:
695
+ """Get map height."""
696
+ return self._map_height
697
+
698
+ @property
699
+ def image(self) -> Optional[QPixmap]:
700
+ """Get the current map image."""
701
+ if self._map_item is not None:
702
+ return self._map_item.pixmap()
703
+ return None
704
+
705
+ def clear_selection(self):
706
+ """Clear current selection."""
707
+ self._selected_cell = None
708
+ self._update_selection()
709
+ self.selectChanged.emit(None)
710
+
711
+ def clear_current(self):
712
+ """Clear current hover cell."""
713
+ self._current_cell = None
714
+ self._update_highlight()
715
+ self.currentChanged.emit(None)
716
+
717
+ def cleanup(self):
718
+ """Clean up temporary files."""
719
+ if self._temp_image_path and os.path.exists(self._temp_image_path):
720
+ try:
721
+ os.remove(self._temp_image_path)
722
+ print(f"[DEBUG] Cleaned up temporary file: {self._temp_image_path}") # 调试输出
723
+ except Exception as e:
724
+ print(f"[DEBUG] Failed to cleanup temporary file: {e}") # 调试输出
725
+
726
+ def __del__(self):
727
+ """Destructor to ensure cleanup."""
728
+ self.cleanup()