import * as PIXI from "pixi.js-legacy"; /** * Costume Editor - A comprehensive sprite/costume editor * Supports drawing, shapes, text, and editing existing costumes */ let editorApp = null; let editorContainer = null; let editorCanvas = null; let currentTool = 'pen'; let fillColor = '#000000'; let outlineColor = '#000000'; let outlineSize = 2; let fillEnabled = true; let penSize = 2; let isDrawing = false; let objects = []; // All drawable objects on canvas let selectedObject = null; let dragOffset = { x: 0, y: 0 }; let isDragging = false; let tempGraphics = null; let startPoint = null; let resizeHandles = []; let isResizing = false; let resizeHandle = null; let shiftPressed = false; let snapToGrid = false; let currentStroke = null; let lastDrawPoint = null; let undoStack = []; let redoStack = []; const MAX_UNDO_STEPS = 50; const GRID_SIZE = 20; let penGraphics = null; let isPenActive = false; const SNAP_THRESHOLD = 10; let initialSize = { width: 0, height: 0 }; let initialPosition = { x: 0, y: 0 }; const CANVAS_WIDTH = 720; const CANVAS_HEIGHT = 480; export function openCostumeEditor(existingCostume = null, onSave) { closeCostumeEditor(); // Close any existing editor // Create editor container editorContainer = document.createElement('div'); editorContainer.className = 'costume-editor-overlay'; editorContainer.innerHTML = `

Costume Editor

2px
2px
`; document.body.appendChild(editorContainer); // Initialize PIXI app editorApp = new PIXI.Application({ width: CANVAS_WIDTH, height: CANVAS_HEIGHT, backgroundAlpha: 0, antialias: true, }); editorCanvas = document.getElementById('costume-canvas'); editorCanvas.appendChild(editorApp.view); // Add checkerboard background to show transparency const checkerboard = new PIXI.Graphics(); const squareSize = 10; for (let y = 0; y < CANVAS_HEIGHT; y += squareSize) { for (let x = 0; x < CANVAS_WIDTH; x += squareSize) { const isEven = ((x / squareSize) + (y / squareSize)) % 2 === 0; checkerboard.beginFill(isEven ? 0xCCCCCC : 0xFFFFFF); checkerboard.drawRect(x, y, squareSize, squareSize); checkerboard.endFill(); } } editorApp.stage.addChildAt(checkerboard, 0); // Create a container for drawn paths tempGraphics = new PIXI.Graphics(); editorApp.stage.addChild(tempGraphics); // Load existing costume if provided if (existingCostume) { loadExistingCostume(existingCostume); } // Setup event listeners setupEditorEvents(onSave); } function loadExistingCostume(costume) { const sprite = new PIXI.Sprite(costume.texture); // Center the sprite sprite.anchor.set(0.5); sprite.x = CANVAS_WIDTH / 2; sprite.y = CANVAS_HEIGHT / 2; // Make it selectable and movable sprite.eventMode = 'static'; sprite.isObject = true; sprite.objectType = 'image'; editorApp.stage.addChild(sprite); objects.push(sprite); } function saveState() { try { // Save a snapshot of the entire stage (excluding UI elements) const snapshot = editorApp.renderer.extract.canvas(editorApp.stage); const dataURL = snapshot.toDataURL(); // Also save the objects array structure for reconstruction const objectsData = objects.map(obj => ({ type: obj.objectType, x: obj.x, y: obj.y, scaleX: obj.scale.x, scaleY: obj.scale.y, angle: obj.angle || 0 })); undoStack.push({ canvas: dataURL, objects: objectsData }); if (undoStack.length > MAX_UNDO_STEPS) { undoStack.shift(); } redoStack = []; updateUndoRedoButtons(); console.log('State saved, undo stack size:', undoStack.length); } catch (e) { console.error('Failed to save state:', e); } } function serializeObject(obj) { try { if (obj.objectType === 'path' || obj.objectType === 'line' || obj.objectType === 'rect' || obj.objectType === 'circle') { // Use canvas to extract the graphics as data URL const bounds = obj.getBounds(); const renderTexture = PIXI.RenderTexture.create({ width: Math.max(1, bounds.width), height: Math.max(1, bounds.height) }); // Render the object to the texture const matrix = new PIXI.Matrix(); matrix.translate(-bounds.x, -bounds.y); editorApp.renderer.render(obj, { renderTexture, transform: matrix }); // Extract as canvas then to data URL const canvas = editorApp.renderer.extract.canvas(renderTexture); const dataURL = canvas.toDataURL(); // Clean up renderTexture.destroy(true); return { texture: dataURL, x: obj.x, y: obj.y, scaleX: obj.scale.x, scaleY: obj.scale.y, boundsX: bounds.x, boundsY: bounds.y, width: bounds.width, height: bounds.height }; } else if (obj.objectType === 'text') { return { text: obj.text, style: { fontFamily: obj.style.fontFamily, fontSize: obj.style.fontSize, fill: obj.style.fill, align: obj.style.align }, x: obj.x, y: obj.y }; } else if (obj.objectType === 'image') { return { texture: obj.texture.baseTexture.resource.url, x: obj.x, y: obj.y, scaleX: obj.scale.x, scaleY: obj.scale.y, angle: obj.angle }; } } catch (e) { console.error('Error serializing object:', e, obj); return null; } } function undo() { if (undoStack.length === 0) return; console.log('Undo called'); // Save current to redo try { const snapshot = editorApp.renderer.extract.canvas(editorApp.stage); const dataURL = snapshot.toDataURL(); const objectsData = objects.map(obj => ({ type: obj.objectType, x: obj.x, y: obj.y, scaleX: obj.scale.x, scaleY: obj.scale.y, angle: obj.angle || 0 })); redoStack.push({ canvas: dataURL, objects: objectsData }); } catch (e) { console.error('Failed to save current state for redo:', e); } // Restore previous state const state = undoStack.pop(); restoreState(state); updateUndoRedoButtons(); } function redo() { if (redoStack.length === 0) return; console.log('Redo called'); // Save current to undo try { const snapshot = editorApp.renderer.extract.canvas(editorApp.stage); const dataURL = snapshot.toDataURL(); const objectsData = objects.map(obj => ({ type: obj.objectType, x: obj.x, y: obj.y, scaleX: obj.scale.x, scaleY: obj.scale.y, angle: obj.angle || 0 })); undoStack.push({ canvas: dataURL, objects: objectsData }); } catch (e) { console.error('Failed to save current state for undo:', e); } // Restore redo state const state = redoStack.pop(); restoreState(state); updateUndoRedoButtons(); } function restoreState(state) { // Clear all objects objects.forEach(obj => { if (obj && !obj.destroyed) { editorApp.stage.removeChild(obj); if (obj.destroy) obj.destroy(); } }); objects = []; // Load the canvas snapshot as a single sprite const texture = PIXI.Texture.from(state.canvas); const sprite = new PIXI.Sprite(texture); sprite.x = 0; sprite.y = 0; sprite.isObject = true; sprite.objectType = 'snapshot'; sprite.eventMode = 'static'; editorApp.stage.addChild(sprite); objects.push(sprite); selectedObject = null; clearHighlight(); clearResizeHandles(); } function updateUndoRedoButtons() { const undoBtn = document.getElementById('undo-button'); const redoBtn = document.getElementById('redo-button'); if (undoBtn) undoBtn.disabled = undoStack.length === 0; if (redoBtn) redoBtn.disabled = redoStack.length === 0; } function setupEditorEvents(onSave) { // Tool selection document.querySelectorAll('.tool-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentTool = btn.dataset.tool; selectedObject = null; }); }); // Fill color picker const fillColorPicker = document.getElementById('fill-color-picker'); fillColorPicker.addEventListener('input', (e) => { fillColor = e.target.value; if (selectedObject && selectedObject.objectType === 'text') { selectedObject.style.fill = fillColor; } }); // Fill enabled checkbox const fillEnabledCheckbox = document.getElementById('fill-enabled'); fillEnabledCheckbox.addEventListener('change', (e) => { fillEnabled = e.target.checked; }); // Outline color picker const outlineColorPicker = document.getElementById('outline-color-picker'); outlineColorPicker.addEventListener('input', (e) => { outlineColor = e.target.value; }); // Outline size const outlineSizeInput = document.getElementById('outline-size'); const outlineSizeDisplay = document.getElementById('outline-size-display'); outlineSizeInput.addEventListener('input', (e) => { outlineSize = parseInt(e.target.value); outlineSizeDisplay.textContent = `${outlineSize}px`; }); // Pen size const penSizeSlider = document.getElementById('pen-size'); const sizeDisplay = document.getElementById('size-display'); penSizeSlider.addEventListener('input', (e) => { penSize = parseInt(e.target.value); sizeDisplay.textContent = `${penSize}px`; }); // Clear canvas document.getElementById('clear-canvas').addEventListener('click', () => { if (confirm('Clear entire canvas?')) { clearCanvas(); } }); // Delete selected document.getElementById('delete-selected').addEventListener('click', () => { if (selectedObject) { deleteObject(selectedObject); } }); // Snap to center document.getElementById('snap-to-center').addEventListener('click', () => { if (selectedObject) { const bounds = selectedObject.getBounds(); // Calculate the offset from object position to its visual center const offsetX = (bounds.x + bounds.width / 2) - selectedObject.x; const offsetY = (bounds.y + bounds.height / 2) - selectedObject.y; // Position so the visual center is at canvas center selectedObject.x = (CANVAS_WIDTH / 2) - offsetX; selectedObject.y = (CANVAS_HEIGHT / 2) - offsetY; updateResizeHandles(selectedObject); } }); // Toggle grid snapping document.getElementById('toggle-grid-snap').addEventListener('click', (e) => { snapToGrid = !snapToGrid; e.target.closest('.toggle-btn').classList.toggle('active', snapToGrid); }); // Canvas interactions editorApp.view.addEventListener('pointerdown', handlePointerDown); editorApp.view.addEventListener('pointermove', handlePointerMove); editorApp.view.addEventListener('pointerup', handlePointerUp); // Save button document.querySelector('.save-btn').addEventListener('click', async () => { const dataURL = await captureCanvas(); if (onSave) { onSave(dataURL); } closeCostumeEditor(); }); // Cancel/Close buttons document.querySelector('.cancel-btn').addEventListener('click', closeCostumeEditor); document.querySelector('.close-editor-btn').addEventListener('click', closeCostumeEditor); document.getElementById('undo-button').addEventListener('click', undo); document.getElementById('redo-button').addEventListener('click', redo); } // Keyboard listeners for Shift key window.addEventListener('keydown', (e) => { if (e.key === 'Shift') { shiftPressed = true; } }); window.addEventListener('keyup', (e) => { if (e.key === 'Shift') { shiftPressed = false; } }); function screenToCanvas(screenX, screenY) { const rect = editorApp.view.getBoundingClientRect(); const canvasX = screenX - rect.left; const canvasY = screenY - rect.top; // Account for canvas scaling if any const scaleX = CANVAS_WIDTH / rect.width; const scaleY = CANVAS_HEIGHT / rect.height; return { x: canvasX * scaleX, y: canvasY * scaleY }; } function handlePointerDown(e) { const pos = screenToCanvas(e.clientX, e.clientY); const x = pos.x; const y = pos.y; // Check if clicking on a resize handle for (let handle of resizeHandles) { // ADD THIS CHECK - skip destroyed handles if (!handle || handle.destroyed || !handle.transform) continue; const bounds = handle.getBounds(); if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) { isResizing = true; resizeHandle = handle; selectedObject = handle.targetObject; const objBounds = selectedObject.getBounds(); initialSize = { width: objBounds.width, height: objBounds.height }; initialPosition = { x: objBounds.x, y: objBounds.y }; return; } } if (currentTool === 'select') { const clickedObject = findObjectAt(x, y); if (clickedObject) { selectedObject = clickedObject; isDragging = true; dragOffset.x = x - clickedObject.x; dragOffset.y = y - clickedObject.y; highlightSelected(); createResizeHandles(clickedObject); } else { selectedObject = null; clearHighlight(); clearResizeHandles(); } } else if (currentTool === 'text') { const text = prompt('Enter text:'); if (text) { addText(text, x, y); saveState(); } } else if (currentTool === 'pen') { isDrawing = true; isPenActive = true; // Create fresh graphics object penGraphics = new PIXI.Graphics(); penGraphics.isObject = true; penGraphics.objectType = 'path'; penGraphics.eventMode = 'static'; penGraphics.x = 0; penGraphics.y = 0; // Add to stage editorApp.stage.addChild(penGraphics); objects.push(penGraphics); // Draw initial dot penGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); penGraphics.drawCircle(x, y, penSize / 2); penGraphics.endFill(); // Store the last position lastDrawPoint = { x, y }; } else if (currentTool === 'eraser') { isDrawing = true; lastDrawPoint = { x, y }; // Immediately check for objects to erase eraseAtPoint(x, y); } else if (['line', 'rect', 'circle'].includes(currentTool)) { isDrawing = true; startPoint = { x, y }; } } function createResizeHandles(obj) { clearResizeHandles(); const bounds = obj.getBounds(); const handleSize = 8; const positions = [ { x: bounds.x, y: bounds.y, cursor: 'nwse-resize', type: 'tl' }, // top-left { x: bounds.x + bounds.width, y: bounds.y, cursor: 'nesw-resize', type: 'tr' }, // top-right { x: bounds.x, y: bounds.y + bounds.height, cursor: 'nesw-resize', type: 'bl' }, // bottom-left { x: bounds.x + bounds.width, y: bounds.y + bounds.height, cursor: 'nwse-resize', type: 'br' }, // bottom-right { x: bounds.x + bounds.width / 2, y: bounds.y, cursor: 'ns-resize', type: 't' }, // top { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height, cursor: 'ns-resize', type: 'b' }, // bottom { x: bounds.x, y: bounds.y + bounds.height / 2, cursor: 'ew-resize', type: 'l' }, // left { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2, cursor: 'ew-resize', type: 'r' }, // right ]; positions.forEach(pos => { const handle = new PIXI.Graphics(); handle.beginFill(0x0066FF); handle.drawRect(pos.x - handleSize / 2, pos.y - handleSize / 2, handleSize, handleSize); handle.endFill(); handle.isResizeHandle = true; handle.handleType = pos.type; handle.targetObject = obj; editorApp.stage.addChild(handle); resizeHandles.push(handle); }); } function clearResizeHandles() { resizeHandles.forEach(handle => { if (handle && !handle.destroyed) { editorApp.stage.removeChild(handle); handle.destroy(); } }); resizeHandles = []; } function updateResizeHandles(obj) { if (resizeHandles.length === 0 || !obj || obj.destroyed) return; const bounds = obj.getBounds(); const handleSize = 8; const positions = [ { x: bounds.x, y: bounds.y }, { x: bounds.x + bounds.width, y: bounds.y }, { x: bounds.x, y: bounds.y + bounds.height }, { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, { x: bounds.x + bounds.width / 2, y: bounds.y }, { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }, { x: bounds.x, y: bounds.y + bounds.height / 2 }, { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, ]; resizeHandles.forEach((handle, i) => { handle.clear(); handle.beginFill(0x0066FF); handle.drawRect(positions[i].x - handleSize / 2, positions[i].y - handleSize / 2, handleSize, handleSize); handle.endFill(); }); } function handlePointerMove(e) { const pos = screenToCanvas(e.clientX, e.clientY); const x = pos.x; const y = pos.y; if (isResizing && resizeHandle && selectedObject) { const handleType = resizeHandle.handleType; // Store initial values ONCE if (!selectedObject._resizing) { selectedObject._resizing = true; selectedObject._initialScale = { x: selectedObject.scale.x, y: selectedObject.scale.y }; selectedObject._initialX = selectedObject.x; selectedObject._initialY = selectedObject.y; const bounds = selectedObject.getBounds(); selectedObject._initialBounds = { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, centerX: bounds.x + bounds.width / 2, centerY: bounds.y + bounds.height / 2 }; } const initial = selectedObject._initialBounds; let newScaleX = selectedObject._initialScale.x; let newScaleY = selectedObject._initialScale.y; // Calculate new scale based on handle if (handleType === 'br') { // bottom-right newScaleX = selectedObject._initialScale.x * ((x - initial.x) / initial.width); newScaleY = selectedObject._initialScale.y * ((y - initial.y) / initial.height); } else if (handleType === 'bl') { // bottom-left newScaleX = selectedObject._initialScale.x * ((initial.x + initial.width - x) / initial.width); newScaleY = selectedObject._initialScale.y * ((y - initial.y) / initial.height); } else if (handleType === 'tr') { // top-right newScaleX = selectedObject._initialScale.x * ((x - initial.x) / initial.width); newScaleY = selectedObject._initialScale.y * ((initial.y + initial.height - y) / initial.height); } else if (handleType === 'tl') { // top-left newScaleX = selectedObject._initialScale.x * ((initial.x + initial.width - x) / initial.width); newScaleY = selectedObject._initialScale.y * ((initial.y + initial.height - y) / initial.height); } else if (handleType === 'r') { // right newScaleX = selectedObject._initialScale.x * ((x - initial.x) / initial.width); } else if (handleType === 'l') { // left newScaleX = selectedObject._initialScale.x * ((initial.x + initial.width - x) / initial.width); } else if (handleType === 'b') { // bottom newScaleY = selectedObject._initialScale.y * ((y - initial.y) / initial.height); } else if (handleType === 't') { // top newScaleY = selectedObject._initialScale.y * ((initial.y + initial.height - y) / initial.height); } // Maintain aspect ratio if shift NOT pressed and corner handle if (!shiftPressed && (handleType === 'tl' || handleType === 'tr' || handleType === 'bl' || handleType === 'br')) { const avgScale = (Math.abs(newScaleX) + Math.abs(newScaleY)) / 2; newScaleX = avgScale * Math.sign(newScaleX || 1); newScaleY = avgScale * Math.sign(newScaleY || 1); } // Prevent too small newScaleX = Math.max(0.05, Math.abs(newScaleX)) * Math.sign(newScaleX || 1); newScaleY = Math.max(0.05, Math.abs(newScaleY)) * Math.sign(newScaleY || 1); selectedObject.scale.x = newScaleX; selectedObject.scale.y = newScaleY; // Adjust position to keep opposite corner fixed const newBounds = selectedObject.getBounds(); const centerOffsetX = (newBounds.x + newBounds.width / 2) - initial.centerX; const centerOffsetY = (newBounds.y + newBounds.height / 2) - initial.centerY; selectedObject.x = selectedObject._initialX - centerOffsetX; selectedObject.y = selectedObject._initialY - centerOffsetY; updateResizeHandles(selectedObject); return; } if (currentTool === 'select' && isDragging && selectedObject) { const rawX = x - dragOffset.x; const rawY = y - dragOffset.y; const snapped = snapToGridOrCenter(rawX, rawY); selectedObject.x = snapped.x; selectedObject.y = snapped.y; updateResizeHandles(selectedObject); } else if (isDrawing) { if (currentTool === 'pen' && isDrawing && isPenActive && penGraphics && lastDrawPoint) { // Draw a line segment from last point to current point using filled rectangles const dx = x - lastDrawPoint.x; const dy = y - lastDrawPoint.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > 0) { // Draw circles along the path for smooth line const steps = Math.max(1, Math.floor(distance / (penSize / 4))); for (let i = 0; i <= steps; i++) { const t = i / steps; const px = lastDrawPoint.x + dx * t; const py = lastDrawPoint.y + dy * t; penGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); penGraphics.drawCircle(px, py, penSize / 2); penGraphics.endFill(); } } lastDrawPoint = { x, y }; } else if (currentTool === 'eraser') { if (lastDrawPoint) { eraseAtPoint(x, y); lastDrawPoint = { x, y }; } } else if (currentTool === 'line') { // Preview line tempGraphics.clear(); tempGraphics.lineStyle(penSize, parseInt(fillColor.replace('#', '0x'))); tempGraphics.moveTo(startPoint.x, startPoint.y); tempGraphics.lineTo(x, y); } else if (currentTool === 'rect') { // Preview rectangle tempGraphics.clear(); if (outlineSize > 0) { tempGraphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); } if (fillEnabled) { tempGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); } const width = x - startPoint.x; const height = y - startPoint.y; tempGraphics.drawRect(startPoint.x, startPoint.y, width, height); if (fillEnabled) { tempGraphics.endFill(); } } else if (currentTool === 'circle') { // Preview circle tempGraphics.clear(); if (outlineSize > 0) { tempGraphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); } if (fillEnabled) { tempGraphics.beginFill(parseInt(fillColor.replace('#', '0x'))); } const radius = Math.sqrt(Math.pow(x - startPoint.x, 2) + Math.pow(y - startPoint.y, 2)); tempGraphics.drawCircle(startPoint.x, startPoint.y, radius); if (fillEnabled) { tempGraphics.endFill(); } } } } function handlePointerUp(e) { const pos = screenToCanvas(e.clientX, e.clientY); const x = pos.x; const y = pos.y; // Clear resize state if (selectedObject && selectedObject._resizing) { delete selectedObject._resizing; delete selectedObject._initialScale; delete selectedObject._initialX; delete selectedObject._initialY; delete selectedObject._initialBounds; } // Finish pen drawing if (currentTool === 'pen' && isPenActive) { isPenActive = false; penGraphics = null; } // Save state after drawing - but only if we actually drew something // Save state after any drawing action if (isDrawing || isPenActive) { setTimeout(() => saveState(), 50); } // Finish erasing if (currentTool === 'eraser') { lastDrawPoint = null; } // Finish shape tools if (currentTool === 'line' && isDrawing) { addLine(startPoint.x, startPoint.y, x, y); } else if (currentTool === 'rect' && isDrawing) { addRectangle(startPoint.x, startPoint.y, x, y); } else if (currentTool === 'circle' && isDrawing) { addCircle(startPoint.x, startPoint.y, x, y); } // Reset all interaction states isDrawing = false; isDragging = false; isResizing = false; resizeHandle = null; startPoint = null; tempGraphics.clear(); } function eraseAtPoint(x, y) { const eraserRadius = penSize * 2; // Check all objects and delete ones that intersect objects = objects.filter(obj => { if (obj === currentStroke) return true; // Don't erase the current stroke being drawn const bounds = obj.getBounds(); // Check if eraser point is inside object bounds const isInside = x >= bounds.x - eraserRadius && x <= bounds.x + bounds.width + eraserRadius && y >= bounds.y - eraserRadius && y <= bounds.y + bounds.height + eraserRadius; if (isInside) { // Delete immediately editorApp.stage.removeChild(obj); if (obj.destroy) obj.destroy(); clearResizeHandles(); // Clear handles if we erased the selected object selectedObject = null; return false; } return true; }); } function addLine(x1, y1, x2, y2) { const graphics = new PIXI.Graphics(); graphics.lineStyle(penSize, parseInt(fillColor.replace('#', '0x'))); graphics.moveTo(x1, y1); graphics.lineTo(x2, y2); graphics.isObject = true; graphics.objectType = 'line'; graphics.eventMode = 'static'; editorApp.stage.addChild(graphics); objects.push(graphics); } function addRectangle(x1, y1, x2, y2) { const graphics = new PIXI.Graphics(); // Set outline if (outlineSize > 0) { graphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); } // Set fill if (fillEnabled) { graphics.beginFill(parseInt(fillColor.replace('#', '0x'))); } const width = x2 - x1; const height = y2 - y1; graphics.drawRect(x1, y1, width, height); if (fillEnabled) { graphics.endFill(); } graphics.isObject = true; graphics.objectType = 'rect'; graphics.eventMode = 'static'; editorApp.stage.addChild(graphics); objects.push(graphics); } function addCircle(x1, y1, x2, y2) { const graphics = new PIXI.Graphics(); // Set outline if (outlineSize > 0) { graphics.lineStyle(outlineSize, parseInt(outlineColor.replace('#', '0x'))); } // Set fill if (fillEnabled) { graphics.beginFill(parseInt(fillColor.replace('#', '0x'))); } const radius = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); graphics.drawCircle(x1, y1, radius); if (fillEnabled) { graphics.endFill(); } graphics.isObject = true; graphics.objectType = 'circle'; graphics.eventMode = 'static'; editorApp.stage.addChild(graphics); objects.push(graphics); } function addText(text, x, y) { const style = new PIXI.TextStyle({ fontFamily: 'Arial', fontSize: penSize * 10, fill: fillColor, align: 'center', }); const textObj = new PIXI.Text(text, style); textObj.x = x; textObj.y = y; textObj.anchor.set(0.5); textObj.isObject = true; textObj.objectType = 'text'; textObj.eventMode = 'static'; editorApp.stage.addChild(textObj); objects.push(textObj); } function snapToGridOrCenter(x, y) { let snappedX = x; let snappedY = y; // Snap to center const centerX = CANVAS_WIDTH / 2; const centerY = CANVAS_HEIGHT / 2; if (Math.abs(x - centerX) < SNAP_THRESHOLD) { snappedX = centerX; } if (Math.abs(y - centerY) < SNAP_THRESHOLD) { snappedY = centerY; } // Snap to grid if (snapToGrid) { snappedX = Math.round(snappedX / GRID_SIZE) * GRID_SIZE; snappedY = Math.round(snappedY / GRID_SIZE) * GRID_SIZE; } return { x: snappedX, y: snappedY }; } function findObjectAt(x, y) { // Check objects in reverse order (top to bottom) for (let i = objects.length - 1; i >= 0; i--) { const obj = objects[i]; if (!obj.eventMode || obj.eventMode === 'none') continue; const bounds = obj.getBounds(); if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) { return obj; } } return null; } function highlightSelected() { clearHighlight(); if (selectedObject && selectedObject.objectType !== 'path' && selectedObject.objectType !== 'eraser') { const bounds = selectedObject.getBounds(); const highlight = new PIXI.Graphics(); highlight.lineStyle(2, 0x0000FF, 0.5); highlight.drawRect(bounds.x - 2, bounds.y - 2, bounds.width + 4, bounds.height + 4); highlight.isHighlight = true; editorApp.stage.addChild(highlight); } } function clearHighlight() { editorApp.stage.children.forEach(child => { if (child.isHighlight) { editorApp.stage.removeChild(child); } }); } function deleteObject(obj) { const index = objects.indexOf(obj); if (index > -1) { objects.splice(index, 1); } editorApp.stage.removeChild(obj); if (obj.destroy) { obj.destroy(); } selectedObject = null; clearHighlight(); saveState(); } function clearCanvas() { objects.forEach(obj => { editorApp.stage.removeChild(obj); if (obj.destroy) { obj.destroy(); } }); objects = []; selectedObject = null; clearHighlight(); tempGraphics.clear(); saveState(); } async function captureCanvas() { // Create a temporary container with only the objects const exportContainer = new PIXI.Container(); // Add all drawable objects to the export container objects.forEach(obj => { exportContainer.addChild(obj); }); // Extract the image const base64 = editorApp.renderer.extract.base64(exportContainer); // Put objects back on the stage objects.forEach(obj => { editorApp.stage.addChild(obj); }); return base64; } export function closeCostumeEditor() { if (editorContainer) { editorContainer.remove(); editorContainer = null; } if (editorApp) { editorApp.destroy(true, { children: true }); editorApp = null; } objects = []; selectedObject = null; tempGraphics = null; } // Add CSS styles const style = document.createElement('style'); style.textContent = ` .costume-editor-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 10000; } .costume-editor-modal { background: #2b2b2b; border-radius: 8px; width: 90%; max-width: 800px; max-height: 90vh; display: flex; flex-direction: column; color: white; } .costume-editor-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid #444; } .costume-editor-header h2 { margin: 0; font-size: 20px; } .close-editor-btn { background: none; border: none; color: white; font-size: 30px; cursor: pointer; padding: 0; width: 30px; height: 30px; line-height: 30px; } .close-editor-btn:hover { color: #ff4444; } .costume-editor-content { display: flex; flex-direction: column; flex: 1; overflow: hidden; } .costume-editor-toolbar { display: flex; gap: 20px; padding: 15px 20px; border-bottom: 1px solid #444; flex-wrap: wrap; background: #333; } .tool-group { display: flex; gap: 8px; align-items: center; } .tool-btn { background: #444; border: 2px solid transparent; color: white; padding: 8px 12px; border-radius: 4px; cursor: pointer; transition: all 0.2s; } .tool-btn:hover { background: #555; } .tool-btn.active { background: #0066ff; border-color: #0044cc; } .action-btn { background: #444; border: none; color: white; padding: 8px 12px; border-radius: 4px; cursor: pointer; transition: all 0.2s; } .action-btn:hover { background: #555; } .tool-group label { display: flex; align-items: center; gap: 8px; font-size: 14px; } #color-picker { width: 40px; height: 30px; border: none; cursor: pointer; } #pen-size { width: 100px; } .costume-editor-canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; background: #1a1a1a; overflow: auto; padding: 20px; } #costume-canvas { border: 2px solid #444; border-radius: 4px; } #costume-canvas canvas { display: block; } .costume-editor-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 15px 20px; border-top: 1px solid #444; } .costume-editor-footer button { padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; transition: all 0.2s; } .cancel-btn { background: #555; color: white; } .cancel-btn:hover { background: #666; } .save-btn { background: #0066ff; color: white; } .save-btn:hover { background: #0055dd; } .primary { font-weight: bold; } #size-display { font-size: 12px; color: #aaa; min-width: 40px; } .toggle-btn.active { background: #0066ff !important; border-color: #0044cc; } `; document.head.appendChild(style);