diff --git a/package.json b/package.json index bbdfbc1d..1e6e3cdf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "npm run build --workspaces --if-present", "build:moxijs": "npm run build --workspace=@moxijs/core && npm run build --workspace=@moxijs/ui && npm run build --workspace=moxijs-examples", + "build:pikcell": "npm run build --workspace=@moxijs/pikcell", "build:clean": "npm run build:clean --workspaces --if-present", "clean": "npm run clean --workspaces --if-present", "build:dev": "npm run build:dev --workspaces --if-present", diff --git a/packages/pikcell/package.json b/packages/pikcell/package.json index f731e7fd..0111581e 100644 --- a/packages/pikcell/package.json +++ b/packages/pikcell/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "dev": "webpack serve --mode development --open", + "dev": "webpack serve --mode development", "build": "webpack --mode production", "build:app": "webpack --mode development", "build:clean": "npm run clean && npm run build", diff --git a/packages/pikcell/src/cards/animation-preview-card.ts b/packages/pikcell/src/cards/animation-preview-card.ts new file mode 100644 index 00000000..6c45b635 --- /dev/null +++ b/packages/pikcell/src/cards/animation-preview-card.ts @@ -0,0 +1,743 @@ +/** + * Animation Preview Card + * + * UI for animation preview with controls in the title bar. + * Supports multiple animation sequences (rows). + * + * Layout: + * +----------------------------------------+ + * | [<][|>][>] [*][X] | <- Playback left, settings/close right + * +----------------------------------------+ + * | +------------+ | + * | | Preview | | + * | +------------+ | + * | [|][|][|][+][++] | <- Row 1: ticks, add frame, add row + * | [X][|][|][+] | <- Row 2+: remove, ticks, add frame + * +----------------------------------------+ + */ +import * as PIXI from 'pixi.js'; +import { GRID, px } from '@moxijs/ui'; +import { createManagedCard } from '../utilities/managed-card'; +import { createCardZoomHandler } from '../utilities/card-zoom-handler'; +import { CardResult } from '../interfaces/components'; +import { ANIMATION_PREVIEW_CARD_CONFIG } from '../config/card-configs'; +import { ANIMATION_CONSTANTS } from '../config/constants'; +import { AnimationController } from '../controllers/animation-controller'; +import { SpriteSheetController } from '../controllers/sprite-sheet-controller'; +import { + AnimationSequenceConfig, + SpriteFrameRef, + createDefaultSequence +} from '../interfaces/animation-types'; +import { getTheme, createText } from '../theming/theme'; +import { createPixelButton } from '../components/pixel-button'; +import { SPRITE_CONTROLLER_CONFIG } from '../config/controller-configs'; +import { createConfirmDialog } from '../components/confirm-dialog'; + +export interface AnimationPreviewCardOptions { + id: string; + x: number; + y: number; + renderer: PIXI.Renderer; + spriteSheetController: SpriteSheetController; + initialSequences?: AnimationSequenceConfig[]; + onSequenceChange?: (sequences: AnimationSequenceConfig[]) => void; + onClose?: () => void; + onFocus?: () => void; + onSelectionModeChange?: (isSelecting: boolean) => void; + onShowSettings?: () => void; + /** Called when current frame changes during playback */ + onFrameChange?: (frameIndex: number) => void; + /** Called when hovering over a frame tick bar (for spritesheet highlight) */ + onFrameHover?: (frameIndex: number, cellX: number, cellY: number) => void; + /** Called when mouse leaves frame tick bars */ + onFrameHoverEnd?: () => void; + /** Container for tooltips (for z-ordering above other UI) */ + tooltipLayer?: PIXI.Container; +} + +export interface AnimationPreviewCardResult extends CardResult { + id: string; + controller: AnimationController; + play: () => void; + pause: () => void; + stop: () => void; + togglePlayback: () => void; + setSequence: (sequence: AnimationSequenceConfig) => void; + getSequence: () => AnimationSequenceConfig; + getSequences: () => AnimationSequenceConfig[]; + addFrame: (frame: SpriteFrameRef) => void; + removeFrame: (index: number) => void; + refresh: () => void; + isSelectingFrames: () => boolean; + setSelectingFrames: (selecting: boolean) => void; + /** Called when user clicks a cell on sprite sheet while in selection mode */ + handleCellClick: (cellX: number, cellY: number) => void; + /** Get frames for highlighting on sprite sheet (all sequences combined) */ + getFrameCells: () => Array<{ cellX: number; cellY: number }>; + /** Get current frame index for highlight updates */ + getCurrentFrameIndex: () => number; +} + +/** + * Creates an animation preview card with multiple sequence support + */ +export function createAnimationPreviewCard(options: AnimationPreviewCardOptions): AnimationPreviewCardResult { + const { + id, + x, + y, + renderer, + spriteSheetController, + initialSequences, + onSequenceChange, + onClose, + onFocus, + onSelectionModeChange, + onShowSettings, + onFrameChange, + onFrameHover, + onFrameHoverEnd, + tooltipLayer + } = options; + + const config = ANIMATION_PREVIEW_CARD_CONFIG; + + // State - multiple sequences + const sequences: AnimationSequenceConfig[] = initialSequences && initialSequences.length > 0 + ? [...initialSequences] + : [createDefaultSequence()]; + const animControllers: AnimationController[] = []; + + let previewSize: number = config.defaultPreviewSize; + let activeRowIndex = 0; + let selectingRowIndex = -1; // -1 means no row is in selection mode + let isPlaying = false; + + // Layout calculations + const rowHeight = 10; // Grid units per row + const baseFrameStripHeight = rowHeight + GRID.padding; + + function getContentHeight() { + return previewSize + (sequences.length * rowHeight) + GRID.padding * 2; + } + + const contentWidth = previewSize + GRID.padding * 4; + + // Calculate minimum content width based on title bar controls + // Left: 3 buttons + 2 spacings, Right: 2 buttons + 1 spacing, plus gap + const btnSize = config.controlButtonSize; + const btnSpacing = 1; + const leftControlsWidth = 3 * btnSize + 2 * btnSpacing; // 26 with btnSize=8 + const rightControlsWidth = 2 * btnSize + 1 * btnSpacing; // 17 with btnSize=8 + const minGap = 2; + // Title bar needs: leftControls + gap + rightControls, minus border offsets + const minTitleBarWidth = leftControlsWidth + minGap + rightControlsWidth; + // Convert to content width (account for card borders/padding) + const minContentWidth = minTitleBarWidth - GRID.border * 2 - GRID.padding * 2; + const minContentHeight = config.minPreviewSize + rowHeight + GRID.padding * 2; + + // Create the managed card + const managed = createManagedCard({ + title: '', + x, + y, + contentWidth, + contentHeight: getContentHeight(), + minContentWidth, + minContentHeight, + renderer, + onFocus, + titleBarExtraHeight: 1, + onResize: (newWidth, newHeight) => { + const availableWidth = newWidth - GRID.padding * 4; + const availableHeight = newHeight - (sequences.length * rowHeight) - GRID.padding * 2; + previewSize = Math.max( + config.minPreviewSize, + Math.min(config.maxPreviewSize, Math.min(availableWidth, availableHeight)) + ); + addTitleBarButtons(); + redrawContent(); + }, + onRefresh: () => { + addTitleBarButtons(); + redrawContent(); + } + }); + + const { card, contentContainer } = managed; + + // Title bar button references (recreated on resize) + const titleBarButtons: Array<{ destroy: () => void }> = []; + let playPauseBtn: ReturnType; + + /** + * Creates/recreates title bar buttons after card resize + */ + function addTitleBarButtons() { + // Clean up existing buttons + titleBarButtons.forEach(btn => { + if (btn.destroy) btn.destroy(); + }); + titleBarButtons.length = 0; + + const btnSize = config.controlButtonSize; + const btnSpacing = 1; + const cardTotalWidth = contentWidth + GRID.border * 6 + GRID.padding * 2; + const btnY = px(GRID.border * 2); + + // === RIGHT-ALIGNED: Settings and Close === + const rightEdge = cardTotalWidth - GRID.border * 2 - 1; + let rightX = rightEdge; + + rightX -= btnSize; + const closeBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: 'X', + tooltip: 'Close', + tooltipLayer, + onFocus, + onClick: () => { + onClose?.(); + } + }); + closeBtn.container.position.set(px(rightX), btnY); + card.container.addChild(closeBtn.container); + titleBarButtons.push(closeBtn); + + rightX -= btnSpacing + btnSize; + const settingsBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: '*', + tooltip: 'Settings', + tooltipLayer, + onFocus, + onClick: () => { + onShowSettings?.(); + } + }); + settingsBtn.container.position.set(px(rightX), btnY); + card.container.addChild(settingsBtn.container); + titleBarButtons.push(settingsBtn); + + // === LEFT-ALIGNED: Playback controls === + let leftX = GRID.border * 2; + + const backBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: '<', + tooltip: 'Step Back', + tooltipLayer, + onFocus, + onClick: () => { + animControllers[activeRowIndex]?.stepBackward(); + } + }); + backBtn.container.position.set(px(leftX), btnY); + card.container.addChild(backBtn.container); + titleBarButtons.push(backBtn); + + leftX += btnSize + btnSpacing; + playPauseBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: isPlaying ? '||' : '|>', + tooltip: isPlaying ? 'Pause' : 'Play', + tooltipLayer, + onFocus, + onClick: () => { + animControllers[activeRowIndex]?.togglePlayback(); + } + }); + playPauseBtn.container.position.set(px(leftX), btnY); + card.container.addChild(playPauseBtn.container); + titleBarButtons.push(playPauseBtn); + + leftX += btnSize + btnSpacing; + const forwardBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: '>', + tooltip: 'Step Forward', + tooltipLayer, + onFocus, + onClick: () => { + animControllers[activeRowIndex]?.stepForward(); + } + }); + forwardBtn.container.position.set(px(leftX), btnY); + card.container.addChild(forwardBtn.container); + titleBarButtons.push(forwardBtn); + } + + // Create animation controllers for each sequence + function createControllerForSequence(seq: AnimationSequenceConfig, index: number): AnimationController { + return new AnimationController({ + spriteSheetController, + sequence: seq, + scale: ANIMATION_CONSTANTS.DEFAULT_PREVIEW_SCALE, + onFrameChange: (frameIndex, texture) => { + if (index === activeRowIndex) { + updatePreviewSprite(texture); + onFrameChange?.(frameIndex); + } + redrawFrameStrips(); + }, + onPlayStateChange: (playing) => { + if (index === activeRowIndex) { + isPlaying = playing; + updatePlayPauseButton(); + } + } + }); + } + + // Initialize controllers + sequences.forEach((seq, i) => { + animControllers.push(createControllerForSequence(seq, i)); + }); + + // Initial title bar buttons + addTitleBarButtons(); + + function updatePlayPauseButton() { + // Recreate all title bar buttons with updated state + addTitleBarButtons(); + } + + // Containers + let previewContainer: PIXI.Container = new PIXI.Container(); + let frameStripsContainer: PIXI.Container = new PIXI.Container(); + let previewSprite: PIXI.Sprite | null = null; + + contentContainer.addChild(previewContainer); + contentContainer.addChild(frameStripsContainer); + + function updatePreviewSprite(texture: PIXI.Texture) { + if (previewSprite) { + previewSprite.texture = texture; + } + } + + function redrawPreview() { + previewContainer.removeChildren(); + + const theme = getTheme(); + const cellSize = SPRITE_CONTROLLER_CONFIG.cellSize; + const previewSizePx = px(previewSize); + const scale = Math.floor(previewSizePx / cellSize); + + const bg = new PIXI.Graphics(); + bg.roundPixels = true; + bg.rect(0, 0, previewSizePx, previewSizePx); + bg.fill({ color: theme.cardTitleBar }); + previewContainer.addChild(bg); + + const activeController = animControllers[activeRowIndex]; + const texture = activeController?.getCurrentFrameTexture(); + // Check that texture is valid and not empty + const hasValidTexture = texture && texture !== PIXI.Texture.EMPTY && texture.source; + if (hasValidTexture) { + previewSprite = new PIXI.Sprite(texture); + previewSprite.scale.set(scale); + previewSprite.roundPixels = true; + previewSprite.position.set( + (previewSizePx - cellSize * scale) / 2, + (previewSizePx - cellSize * scale) / 2 + ); + previewContainer.addChild(previewSprite); + } else { + previewSprite = null; + const seq = sequences[activeRowIndex]; + const noFramesText = createText( + seq?.frames?.length > 0 ? 'Loading...' : 'No frames', + theme.text + ); + noFramesText.position.set( + (previewSizePx - noFramesText.width) / 2, + (previewSizePx - noFramesText.height) / 2 + ); + previewContainer.addChild(noFramesText); + } + + const contentWidthPx = px(contentWidth); + previewContainer.position.set( + (contentWidthPx - previewSizePx) / 2, + px(GRID.padding) + ); + } + + /** + * Draw a single frame strip row + * Layout: [X](non-first) [ticks][+] [++](first row, right-aligned) + */ + function drawFrameStripRow( + container: PIXI.Container, + rowIndex: number, + yOffset: number + ) { + const theme = getTheme(); + const seq = sequences[rowIndex]; + const controller = animControllers[rowIndex]; + const frames = seq.frames; + const currentFrameIndex = controller?.getCurrentFrameIndex() ?? -1; + const isActiveRow = rowIndex === activeRowIndex; + const isSelectingRow = rowIndex === selectingRowIndex; + const isFirstRow = rowIndex === 0; + + const tickWidth = px(2); + const tickHeight = px(8); + const tickSpacing = px(1); + const btnSize = config.controlButtonSize; + const btnSizePx = px(btnSize); + const margin = px(2); + const contentWidthPx = px(contentWidth); + const rowHeightPx = px(rowHeight); + + // Draw active row background + if (isActiveRow) { + const rowBg = new PIXI.Graphics(); + rowBg.roundPixels = true; + rowBg.rect(0, yOffset - px(1), contentWidthPx, rowHeightPx); + rowBg.fill({ color: theme.cardBorder, alpha: 0.15 }); + container.addChild(rowBg); + } + + // Calculate layout - start with left margin + let rowStartX = margin; + + // Add [X] button for non-first rows (left-aligned with margin) + // For first row, add equivalent spacing so tick bars align + if (!isFirstRow) { + const removeBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: 'X', + backgroundColor: 0x882222, // Red tint for delete + onFocus, + onClick: () => { + // Show confirmation dialog + const dialog = createConfirmDialog({ + title: 'Remove Animation', + message: `Remove animation row ${rowIndex + 1}?`, + onConfirm: () => { + removeRow(rowIndex); + }, + renderer + }); + card.container.parent?.addChild(dialog.container); + } + }); + managed.trackChild(removeBtn); + removeBtn.container.position.set(rowStartX, yOffset + (tickHeight - btnSizePx) / 2); + container.addChild(removeBtn.container); + } + // Always add button width + margin to align tick bars across all rows + rowStartX += btnSizePx + margin; + + // Draw tick bars + frames.forEach((frameRef, index) => { + const tickX = rowStartX + index * (tickWidth + tickSpacing); + + const tickBar = new PIXI.Graphics(); + tickBar.roundPixels = true; + tickBar.eventMode = 'static'; + tickBar.cursor = 'pointer'; + + const isCurrentFrame = index === currentFrameIndex && isActiveRow; + const barColor = isCurrentFrame ? theme.accent : (isActiveRow ? theme.cardBorder : 0x555555); + const barAlpha = isCurrentFrame ? 1.0 : (isActiveRow ? 0.6 : 0.4); + + tickBar.roundRect(tickX, yOffset, tickWidth, tickHeight, 1); + tickBar.fill({ color: barColor, alpha: barAlpha }); + + if (isCurrentFrame) { + tickBar.roundRect(tickX, yOffset, tickWidth, tickHeight, 1); + tickBar.stroke({ color: 0xffffff, width: 1, alpha: 0.3 }); + } + + container.addChild(tickBar); + + tickBar.on('pointerdown', (e: PIXI.FederatedPointerEvent) => { + onFocus?.(); + setActiveRow(rowIndex); + controller?.goToFrame(index); + e.stopPropagation(); + }); + + tickBar.on('rightclick', (e: PIXI.FederatedPointerEvent) => { + onFocus?.(); + setActiveRow(rowIndex); + controller?.removeFrame(index); + onSequenceChange?.(sequences); + redrawContent(); + e.stopPropagation(); + }); + + // Hover handlers for spritesheet cell highlighting + tickBar.on('pointerover', () => { + onFrameHover?.(index, frameRef.cellX, frameRef.cellY); + }); + + tickBar.on('pointerout', () => { + onFrameHoverEnd?.(); + }); + }); + + // Add frame button [+] after tick bars + const addX = rowStartX + frames.length * (tickWidth + tickSpacing) + px(1); + const addBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'toggle', + selected: isSelectingRow, + label: '+', + onFocus, + onClick: () => { + if (selectingRowIndex === rowIndex) { + // Turn off selection mode + selectingRowIndex = -1; + onSelectionModeChange?.(false); + } else { + // Turn on selection mode for this row + selectingRowIndex = rowIndex; + setActiveRow(rowIndex); + onSelectionModeChange?.(true); + } + redrawFrameStrips(); + } + }); + managed.trackChild(addBtn); + addBtn.container.position.set(addX, yOffset + (tickHeight - btnSizePx) / 2); + container.addChild(addBtn.container); + + // Add row button [++] only on first row - RIGHT ALIGNED with margin + if (isFirstRow) { + const addRowX = contentWidthPx - btnSizePx - margin; + const addRowBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: '++', + onFocus, + onClick: () => { + addRow(); + } + }); + managed.trackChild(addRowBtn); + addRowBtn.container.position.set(addRowX, yOffset + (tickHeight - btnSizePx) / 2); + container.addChild(addRowBtn.container); + } + } + + function redrawFrameStrips() { + frameStripsContainer.removeChildren(); + managed.clearChildren(); + + const previewSizePx = px(previewSize); + const startY = px(GRID.padding) + previewSizePx + px(2); + + sequences.forEach((_, rowIndex) => { + const yOffset = startY + rowIndex * px(rowHeight); + drawFrameStripRow(frameStripsContainer, rowIndex, yOffset - startY); + }); + + frameStripsContainer.position.set(0, startY); + } + + function addRow() { + if (sequences.length >= ANIMATION_CONSTANTS.MAX_FRAMES_PER_SEQUENCE) return; + + const newSeq = createDefaultSequence(); + sequences.push(newSeq); + + const newController = createControllerForSequence(newSeq, sequences.length - 1); + animControllers.push(newController); + + // Update card size + updateCardSize(); + onSequenceChange?.(sequences); + redrawContent(); + } + + function removeRow(rowIndex: number) { + if (rowIndex === 0 || rowIndex >= sequences.length) return; + + // Clean up controller + animControllers[rowIndex]?.destroy(); + animControllers.splice(rowIndex, 1); + sequences.splice(rowIndex, 1); + + // Adjust active row if needed + if (activeRowIndex >= sequences.length) { + activeRowIndex = sequences.length - 1; + } + if (selectingRowIndex === rowIndex) { + selectingRowIndex = -1; + onSelectionModeChange?.(false); + } else if (selectingRowIndex > rowIndex) { + selectingRowIndex--; + } + + // Update card size + updateCardSize(); + onSequenceChange?.(sequences); + redrawContent(); + } + + function setActiveRow(index: number) { + if (index < 0 || index >= sequences.length) return; + + // Pause previous active controller + animControllers[activeRowIndex]?.pause(); + + activeRowIndex = index; + isPlaying = animControllers[activeRowIndex]?.isAnimating() ?? false; + updatePlayPauseButton(); + redrawContent(); + } + + function updateCardSize() { + const newHeight = getContentHeight(); + card.setContentSize(contentWidth, newHeight); + } + + function redrawContent() { + redrawPreview(); + redrawFrameStrips(); + } + + // Initial draw + redrawContent(); + + // Mouse wheel zoom for preview size (height changes, width stays same) + const wheelHandler = createCardZoomHandler(renderer, card, (delta) => { + const newPreviewSize = Math.max( + config.minPreviewSize, + Math.min(config.maxPreviewSize, previewSize + delta * 2) + ); + + if (newPreviewSize !== previewSize) { + previewSize = newPreviewSize; + + // Update card height (width stays the same) + const newHeight = getContentHeight(); + card.setContentSize(contentWidth, newHeight); + + addTitleBarButtons(); + redrawContent(); + } + }); + + if (typeof window !== 'undefined') { + managed.addEventListenerTracked(window, 'wheel', wheelHandler, { passive: false }); + } + + return { + id, + card, + container: card.container, + controller: animControllers[0], // Return first controller for backwards compatibility + + play: () => animControllers[activeRowIndex]?.play(), + pause: () => animControllers[activeRowIndex]?.pause(), + stop: () => animControllers[activeRowIndex]?.stop(), + togglePlayback: () => animControllers[activeRowIndex]?.togglePlayback(), + + setSequence: (newSequence: AnimationSequenceConfig) => { + Object.assign(sequences[activeRowIndex], newSequence); + animControllers[activeRowIndex]?.setSequence(sequences[activeRowIndex]); + redrawContent(); + }, + + getSequence: () => animControllers[activeRowIndex]?.getSequence() ?? sequences[activeRowIndex], + + getSequences: () => sequences.map((_, i) => animControllers[i]?.getSequence() ?? sequences[i]), + + addFrame: (frame: SpriteFrameRef) => { + animControllers[activeRowIndex]?.addFrame(frame); + onSequenceChange?.(sequences); + redrawContent(); + }, + + removeFrame: (index: number) => { + animControllers[activeRowIndex]?.removeFrame(index); + onSequenceChange?.(sequences); + redrawContent(); + }, + + refresh: () => { + animControllers.forEach(c => c.onSpriteSheetUpdate()); + redrawContent(); + }, + + isSelectingFrames: () => selectingRowIndex >= 0, + + setSelectingFrames: (selecting: boolean) => { + if (selecting) { + selectingRowIndex = activeRowIndex; + } else { + selectingRowIndex = -1; + } + onSelectionModeChange?.(selecting); + redrawFrameStrips(); + }, + + handleCellClick: (cellX: number, cellY: number) => { + if (selectingRowIndex < 0) return; + + const seq = sequences[selectingRowIndex]; + const controller = animControllers[selectingRowIndex]; + + const existingIndex = seq.frames.findIndex( + f => f.cellX === cellX && f.cellY === cellY + ); + + if (existingIndex >= 0) { + controller?.removeFrame(existingIndex); + } else { + controller?.addFrame({ cellX, cellY }); + // Go to the newly added frame (it's now at the end) + const newFrameIndex = seq.frames.length - 1; + controller?.goToFrame(newFrameIndex); + } + + onSequenceChange?.(sequences); + redrawContent(); + }, + + getFrameCells: () => { + // Return all frames from all sequences for highlighting + const allFrames: Array<{ cellX: number; cellY: number }> = []; + sequences.forEach(seq => { + seq.frames.forEach(f => { + if (!allFrames.some(af => af.cellX === f.cellX && af.cellY === f.cellY)) { + allFrames.push({ cellX: f.cellX, cellY: f.cellY }); + } + }); + }); + return allFrames; + }, + + getCurrentFrameIndex: () => animControllers[activeRowIndex]?.getCurrentFrameIndex() ?? 0, + + destroy: () => { + if (selectingRowIndex >= 0) { + selectingRowIndex = -1; + onSelectionModeChange?.(false); + } + animControllers.forEach(c => c.destroy()); + titleBarButtons.forEach(btn => btn.destroy()); + managed.destroy(); + } + }; +} diff --git a/packages/pikcell/src/cards/commander-bar-card.ts b/packages/pikcell/src/cards/commander-bar-card.ts index 37017da9..0a04cfd4 100644 --- a/packages/pikcell/src/cards/commander-bar-card.ts +++ b/packages/pikcell/src/cards/commander-bar-card.ts @@ -20,10 +20,10 @@ interface ButtonDef { } export interface CommanderBarCallbacks { - onNew?: () => void; - onSave?: () => void; - onLoad?: () => void; + onProject?: () => void; onExport?: () => void; + onNewAnimation?: () => void; + onSheets?: () => void; onApplyLayout?: () => void; onSaveLayoutSlot?: (slot: 'A' | 'B' | 'C') => void; onLoadLayoutSlot?: (slot: 'A' | 'B' | 'C') => void; @@ -51,10 +51,10 @@ export interface CommanderBarCardResult { /** Left-side button definitions */ const LEFT_BUTTONS: ButtonDef[] = [ - { label: 'New', callbackKey: 'onNew' }, - { label: 'Save', callbackKey: 'onSave' }, - { label: 'Load', callbackKey: 'onLoad' }, - { label: 'Export', callbackKey: 'onExport' } + { label: 'Project', callbackKey: 'onProject' }, + { label: 'Export', callbackKey: 'onExport' }, + { label: 'Anim', callbackKey: 'onNewAnimation' }, + { label: 'Sheets', callbackKey: 'onSheets' } ]; /** Layout slot identifiers */ diff --git a/packages/pikcell/src/components/animation-settings-dialog.ts b/packages/pikcell/src/components/animation-settings-dialog.ts new file mode 100644 index 00000000..72d05b62 --- /dev/null +++ b/packages/pikcell/src/components/animation-settings-dialog.ts @@ -0,0 +1,233 @@ +/** + * Animation Settings Dialog + * Modal dialog for configuring animation sequence settings + */ +import * as PIXI from 'pixi.js'; +import { PixelCard } from './pixel-card'; +import { GRID, px } from '@moxijs/core'; +import { createPixelButton, PixelButtonResult } from './pixel-button'; +import { createPixelCheckbox, PixelCheckboxResult } from './pixel-checkbox'; +import { getTheme, createText } from '../theming/theme'; +import { ComponentResult } from '../interfaces/components'; +import { AnimationSequenceConfig } from '../interfaces/animation-types'; +import { ANIMATION_CONSTANTS } from '../config/constants'; + +export interface AnimationSettingsDialogOptions { + sequence: AnimationSequenceConfig; + renderer: PIXI.Renderer; + onApply: (settings: AnimationSequenceConfig) => void; + onCancel?: () => void; +} + +export interface AnimationSettingsDialogResult extends ComponentResult { + close(): void; +} + +/** + * Creates an animation settings dialog + */ +export function createAnimationSettingsDialog(options: AnimationSettingsDialogOptions): AnimationSettingsDialogResult { + const { sequence, renderer, onApply, onCancel } = options; + + // Track created components for cleanup + const createdButtons: PixelButtonResult[] = []; + const createdCheckboxes: PixelCheckboxResult[] = []; + + // Editable copy of settings + let frameDurationMs = sequence.frameDurationMs; + let loop = sequence.loop; + let pingPong = sequence.pingPong; + + // Create overlay container + const overlay = new PIXI.Container(); + + // Semi-transparent backdrop + const backdrop = new PIXI.Graphics(); + backdrop.rect(0, 0, renderer.width, renderer.height); + backdrop.fill({ color: 0x000000, alpha: 0.5 }); + backdrop.eventMode = 'static'; + overlay.addChild(backdrop); + + // Dialog dimensions + const contentWidth = 80; // Grid units + const contentHeight = 75; // Grid units - enough for all content + buttons + + // Create dialog card + const dialogCard = new PixelCard({ + title: 'Animation Settings', + x: 0, + y: 0, + contentWidth, + contentHeight, + renderer, + minContentSize: true + }); + + const contentContainer = dialogCard.getContentContainer(); + const theme = getTheme(); + + let currentY = px(4); + + // Cleanup function + const cleanup = () => { + createdButtons.forEach(btn => btn.destroy()); + createdButtons.length = 0; + createdCheckboxes.forEach(cb => cb.destroy()); + createdCheckboxes.length = 0; + overlay.destroy({ children: true }); + }; + + // --- Frame Duration Section --- + const durationLabel = createText('Frame Duration:', theme.text); + durationLabel.position.set(px(4), currentY); + contentContainer.addChild(durationLabel); + currentY += durationLabel.height + px(3); + + // Duration display and controls row + const durationRow = new PIXI.Container(); + durationRow.position.set(px(4), currentY); + + // Decrement button [-] + const decrementBtn = createPixelButton({ + size: 10, + label: '-', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + frameDurationMs = Math.max(ANIMATION_CONSTANTS.MIN_FRAME_DURATION_MS, frameDurationMs - 10); + updateDurationDisplay(); + } + }); + createdButtons.push(decrementBtn); + decrementBtn.container.position.set(0, 0); + durationRow.addChild(decrementBtn.container); + + // Duration value text + const durationValueText = createText(`${frameDurationMs}ms`, theme.text); + durationValueText.position.set(px(16), px(2)); + durationRow.addChild(durationValueText); + + // Increment button [+] + const incrementBtn = createPixelButton({ + size: 10, + label: '+', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + frameDurationMs = Math.min(ANIMATION_CONSTANTS.MAX_FRAME_DURATION_MS, frameDurationMs + 10); + updateDurationDisplay(); + } + }); + createdButtons.push(incrementBtn); + incrementBtn.container.position.set(px(38), 0); + durationRow.addChild(incrementBtn.container); + + // FPS display (calculated from duration) + const fpsText = createText(`(${Math.round(1000 / frameDurationMs)} fps)`, theme.text); + fpsText.position.set(px(54), px(2)); + durationRow.addChild(fpsText); + + contentContainer.addChild(durationRow); + currentY += px(14); + + function updateDurationDisplay() { + durationValueText.text = `${frameDurationMs}ms`; + fpsText.text = `(${Math.round(1000 / frameDurationMs)} fps)`; + } + + // --- Loop Checkbox --- + const loopCheckbox = createPixelCheckbox({ + label: 'Loop', + checked: loop, + onChange: (checked) => { + loop = checked; + } + }); + createdCheckboxes.push(loopCheckbox); + loopCheckbox.container.position.set(px(4), currentY); + contentContainer.addChild(loopCheckbox.container); + currentY += loopCheckbox.container.height + px(4); + + // --- Ping-Pong Checkbox --- + const pingPongCheckbox = createPixelCheckbox({ + label: 'Ping-Pong', + checked: pingPong, + onChange: (checked) => { + pingPong = checked; + } + }); + createdCheckboxes.push(pingPongCheckbox); + pingPongCheckbox.container.position.set(px(4), currentY); + contentContainer.addChild(pingPongCheckbox.container); + currentY += pingPongCheckbox.container.height + px(6); + + // --- Frame Count Info --- + const frameCountText = createText(`Frames: ${sequence.frames.length}`, theme.text); + frameCountText.position.set(px(4), currentY); + contentContainer.addChild(frameCountText); + currentY += frameCountText.height + px(8); + + // --- Buttons Row --- + const buttonWidth = 24; + const buttonHeight = 12; + const buttonSpacingUnits = 6; + const buttonsY = currentY; + + // Calculate total width of both buttons + spacing, then center + const totalButtonsWidth = buttonWidth * 2 + buttonSpacingUnits; + const startX = px((contentWidth - totalButtonsWidth) / 2); + + // Apply button + const applyBtn = createPixelButton({ + width: buttonWidth, + height: buttonHeight, + label: 'Apply', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + // Create updated sequence config + const updatedSequence: AnimationSequenceConfig = { + ...sequence, + frameDurationMs, + loop, + pingPong + }; + onApply(updatedSequence); + cleanup(); + } + }); + createdButtons.push(applyBtn); + applyBtn.container.position.set(startX, buttonsY); + contentContainer.addChild(applyBtn.container); + + // Cancel button + const cancelBtn = createPixelButton({ + width: buttonWidth, + height: buttonHeight, + label: 'Cancel', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + onCancel?.(); + cleanup(); + } + }); + createdButtons.push(cancelBtn); + cancelBtn.container.position.set(startX + px(buttonWidth + buttonSpacingUnits), buttonsY); + contentContainer.addChild(cancelBtn.container); + + // Center dialog on screen + const dialogBounds = dialogCard.container.getBounds(); + dialogCard.container.position.set( + (renderer.width - dialogBounds.width) / 2, + (renderer.height - dialogBounds.height) / 2 + ); + + overlay.addChild(dialogCard.container); + + return { + container: overlay, + close: cleanup, + destroy: cleanup + }; +} diff --git a/packages/pikcell/src/components/confirm-dialog.ts b/packages/pikcell/src/components/confirm-dialog.ts new file mode 100644 index 00000000..dae9f44d --- /dev/null +++ b/packages/pikcell/src/components/confirm-dialog.ts @@ -0,0 +1,62 @@ +/** + * Confirmation Dialog + * Simple confirmation dialog wrapper around PixelDialog + */ +import * as PIXI from 'pixi.js'; +import { createPixelDialog, PixelDialogResult } from './pixel-dialog'; + +export interface ConfirmDialogOptions { + /** Dialog title */ + title: string; + /** Confirmation message */ + message: string; + /** Callback when user confirms */ + onConfirm: () => void; + /** Optional callback when user cancels */ + onCancel?: () => void; + /** Renderer for positioning */ + renderer: PIXI.Renderer; + /** Optional custom confirm button label (default: "Confirm") */ + confirmLabel?: string; + /** Optional custom cancel button label (default: "Cancel") */ + cancelLabel?: string; +} + +export interface ConfirmDialogResult extends PixelDialogResult { + // Inherits from PixelDialogResult +} + +/** + * Creates a confirmation dialog with Confirm/Cancel buttons + */ +export function createConfirmDialog(options: ConfirmDialogOptions): ConfirmDialogResult { + const { + title, + message, + onConfirm, + onCancel, + renderer, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel' + } = options; + + return createPixelDialog({ + title, + message, + renderer, + buttons: [ + { + label: confirmLabel, + onClick: () => { + onConfirm(); + } + }, + { + label: cancelLabel, + onClick: () => { + onCancel?.(); + } + } + ] + }); +} diff --git a/packages/pikcell/src/components/pixel-button.ts b/packages/pikcell/src/components/pixel-button.ts index 687115aa..1d066b21 100644 --- a/packages/pikcell/src/components/pixel-button.ts +++ b/packages/pikcell/src/components/pixel-button.ts @@ -51,6 +51,10 @@ export interface PixelButtonOptions { selectionMode?: SelectionMode; // 'highlight' for swatches, 'press' for tool buttons (visual appearance) actionMode?: ActionMode; // 'click' for simple click, 'toggle' for toggleable state (behavior) tooltip?: string; // Optional tooltip text + /** Container for tooltips - if provided, tooltips render here (for z-ordering) */ + tooltipLayer?: PIXI.Container; + /** Optional callback to trigger when button is clicked (before onClick, useful for focus) */ + onFocus?: () => void; /** Enable pixel explosion effect on click/toggle */ explodeEffect?: boolean; /** Scene container for explosion particles (required if explodeEffect is true) */ @@ -101,6 +105,8 @@ export function createPixelButton(options: PixelButtonOptions): PixelButtonResul selectionMode = 'press', actionMode = 'click', tooltip, + tooltipLayer, + onFocus, explodeEffect = false, explodeScene, explodeScreenHeight = 800, @@ -285,21 +291,39 @@ export function createPixelButton(options: PixelButtonOptions): PixelButtonResul if (tooltipContainer) return; tooltipContainer = createTooltip(); - const local = e.getLocalPosition(button); - tooltipContainer.position.set(local.x, local.y - tooltipContainer.height - 2); - button.addChild(tooltipContainer); + + if (tooltipLayer) { + // Use global coordinates for the tooltip layer + const globalPos = button.getGlobalPosition(); + tooltipContainer.position.set(globalPos.x, globalPos.y - tooltipContainer.height - 2); + tooltipLayer.addChild(tooltipContainer); + } else { + // Fallback: add to button with local coordinates + const local = e.getLocalPosition(button); + tooltipContainer.position.set(local.x, local.y - tooltipContainer.height - 2); + button.addChild(tooltipContainer); + } }); button.on('pointermove', (e: PIXI.FederatedPointerEvent) => { if (tooltipContainer) { - const local = e.getLocalPosition(button); - tooltipContainer.position.set(local.x, local.y - tooltipContainer.height - 2); + if (tooltipLayer) { + const globalPos = button.getGlobalPosition(); + tooltipContainer.position.set(globalPos.x, globalPos.y - tooltipContainer.height - 2); + } else { + const local = e.getLocalPosition(button); + tooltipContainer.position.set(local.x, local.y - tooltipContainer.height - 2); + } } }); button.on('pointerout', () => { if (tooltipContainer) { - button.removeChild(tooltipContainer); + if (tooltipLayer) { + tooltipLayer.removeChild(tooltipContainer); + } else { + button.removeChild(tooltipContainer); + } tooltipContainer.destroy(); tooltipContainer = null; } @@ -393,6 +417,8 @@ export function createPixelButton(options: PixelButtonOptions): PixelButtonResul // Add click handler if provided if (onClick) { button.on('pointerdown', (e: PIXI.FederatedPointerEvent) => { + // Trigger focus callback first (e.g., for card focus) + onFocus?.(); // Trigger explosion if enabled and not already exploding if (explodeEffect && explodeScene && !isExploding) { triggerExplosion(); diff --git a/packages/pikcell/src/components/pixel-card.ts b/packages/pikcell/src/components/pixel-card.ts index df1d3f0f..c9f0205f 100644 --- a/packages/pikcell/src/components/pixel-card.ts +++ b/packages/pikcell/src/components/pixel-card.ts @@ -26,10 +26,13 @@ export interface PixelCardOptions { onResize?: (width: number, height: number) => void; onRefresh?: () => void; // Callback when card is refreshed (e.g., theme change) minContentSize?: boolean; // If true, prevents resizing below content's actual size + minContentWidth?: number; // Explicit minimum content width in grid units + minContentHeight?: number; // Explicit minimum content height in grid units backgroundColor?: number; // Custom background color (defaults to theme.cardBackground) clipContent?: boolean; // If true, clips content to container bounds (like CSS overflow: hidden) pairedCard?: PixelCard; // Optional paired card that should layer together onFocus?: () => void; // Callback when card is clicked/focused + titleBarExtraHeight?: number; // Extra height to add to title bar (in grid units) } export interface PixelCardResizeState { @@ -69,15 +72,26 @@ export class PixelCard { this.contentContainer = new PIXI.Container(); + // Make content container trigger focus on click (same as title bar) + this.contentContainer.eventMode = 'static'; + this.contentContainer.on('pointerdown', () => { + if (this.onFocus) { + this.onFocus(); + } + }); + this.state = { contentWidth: options.contentWidth, contentHeight: options.contentHeight, + minContentWidth: options.minContentWidth, + minContentHeight: options.minContentHeight, }; // Calculate title bar height based on font DISPLAY size (not installation size) const fontHeight = getFontDisplaySize(); const verticalPadding = px(GRID.padding * 2); - this.titleBarHeightPx = Math.ceil(fontHeight + verticalPadding); + const extraHeight = options.titleBarExtraHeight ? px(options.titleBarExtraHeight) : 0; + this.titleBarHeightPx = Math.ceil(fontHeight + verticalPadding + extraHeight); // Initialize drag handler this.dragHandler = new CardDragHandler({ diff --git a/packages/pikcell/src/components/pixel-text-input.ts b/packages/pikcell/src/components/pixel-text-input.ts new file mode 100644 index 00000000..20d133bf --- /dev/null +++ b/packages/pikcell/src/components/pixel-text-input.ts @@ -0,0 +1,125 @@ +/** + * Pixel-perfect text input component for pikcell + * Wraps UITextInput from @moxijs/ui with pikcell styling + */ +import * as PIXI from 'pixi.js'; +import { GRID, px } from '@moxijs/core'; +import { UITextInput, UIFocusManager } from '@moxijs/ui'; +import { getTheme, createText } from '../theming/theme'; +import { ComponentResult } from '../interfaces/components'; + +export interface PixelTextInputOptions { + /** Width in grid units */ + width: number; + /** Height in grid units (default: 12) */ + height?: number; + /** Initial value */ + value?: string; + /** Placeholder text when empty */ + placeholder?: string; + /** Maximum character length */ + maxLength?: number; + /** Callback when value changes */ + onChange?: (value: string) => void; + /** Label to show above the input */ + label?: string; +} + +export interface PixelTextInputResult extends ComponentResult { + /** Get current value */ + getValue(): string; + /** Set current value */ + setValue(value: string): void; + /** Focus the input */ + focus(): void; + /** Blur the input */ + blur(): void; + /** Refresh display (e.g., after theme change) */ + refresh(): void; +} + +/** + * Creates a pixel-styled text input using UITextInput from @moxijs/ui + */ +export function createPixelTextInput(options: PixelTextInputOptions): PixelTextInputResult { + const { + width, + height = 12, + value = '', + placeholder = '', + maxLength = 50, + onChange, + label + } = options; + + const theme = getTheme(); + + // Main container + const container = new PIXI.Container(); + + // Label text (if provided) + let labelText: PIXI.Text | null = null; + let labelHeight = 0; + + if (label) { + labelText = createText(label, theme.text); + labelText.position.set(0, 0); + container.addChild(labelText); + labelHeight = Math.ceil(labelText.height / px(1)) + 2; // 2 grid units gap + } + + // Convert grid units to pixels for UITextInput + const inputWidthPx = px(width); + const inputHeightPx = px(height); + + // Create UITextInput with pikcell theme colors + // Use 16px font size (same as FONT_DISPLAY_SIZE in pikcell theming) + const textInput = new UITextInput({ + defaultValue: value, // Use defaultValue for uncontrolled mode + placeholder: placeholder, + maxLength: maxLength, + width: inputWidthPx, + height: inputHeightPx, + backgroundColor: theme.cardBackground, + textColor: theme.text, + placeholderColor: theme.cardBorder, + borderRadius: 0, // Pixel-perfect, no rounded corners + fontSize: 16, // Match pikcell's FONT_DISPLAY_SIZE + fontFamily: 'PixelOperator8', + onChange: onChange + }); + + // Position the input below the label + textInput.container.position.set(0, px(labelHeight)); + container.addChild(textInput.container); + + // Initialize focus manager if not already done + const focusManager = UIFocusManager.getInstance(); + if (focusManager) { + focusManager.register(textInput); + } + + return { + container, + getValue: () => textInput.getValue(), + setValue: (newValue: string) => textInput.setValue(newValue), + focus: () => textInput.onFocus(), + blur: () => textInput.onBlur(), + refresh: () => { + // Re-apply theme colors if needed + const newTheme = getTheme(); + // UITextInput doesn't have a direct way to update colors after creation + // For now, refresh is a no-op + }, + destroy: () => { + if (focusManager) { + focusManager.unregister(textInput); + } + textInput.destroy(); + if (labelText) { + labelText.destroy(); + } + container.destroy({ children: true }); + } + }; +} diff --git a/packages/pikcell/src/components/project-dialog.ts b/packages/pikcell/src/components/project-dialog.ts new file mode 100644 index 00000000..78222e03 --- /dev/null +++ b/packages/pikcell/src/components/project-dialog.ts @@ -0,0 +1,223 @@ +/** + * Project Dialog + * Modal dialog for project management: New, Open, Save, Save As + */ +import * as PIXI from 'pixi.js'; +import { PixelCard } from './pixel-card'; +import { GRID, BORDER, px } from '@moxijs/core'; +import { createPixelButton, PixelButtonResult } from './pixel-button'; +import { createPixelTextInput, PixelTextInputResult } from './pixel-text-input'; +import { getTheme, createText } from '../theming/theme'; +import { ComponentResult } from '../interfaces/components'; + +export interface ProjectDialogCallbacks { + onNew: () => void; + onOpen: () => void; + onSave: (projectName: string) => void; +} + +export interface ProjectDialogOptions { + renderer: PIXI.Renderer; + callbacks: ProjectDialogCallbacks; + /** Whether there's an existing project open */ + hasProject?: boolean; + /** Whether there are unsaved changes */ + hasUnsavedChanges?: boolean; + /** Current project filename if saved */ + currentFilename?: string | null; + /** Current project name */ + projectName?: string; +} + +export interface ProjectDialogResult extends ComponentResult { + close(): void; +} + +/** + * Creates a project management dialog + */ +export function createProjectDialog(options: ProjectDialogOptions): ProjectDialogResult { + const { + renderer, + callbacks, + hasProject = false, + hasUnsavedChanges = false, + currentFilename = null, + projectName = 'Untitled' + } = options; + + // Track created components for cleanup + const createdButtons: PixelButtonResult[] = []; + let textInput: PixelTextInputResult | null = null; + + // Create overlay container + const overlay = new PIXI.Container(); + + // Semi-transparent backdrop + const backdrop = new PIXI.Graphics(); + backdrop.rect(0, 0, renderer.width, renderer.height); + backdrop.fill({ color: 0x000000, alpha: 0.5 }); + backdrop.eventMode = 'static'; + overlay.addChild(backdrop); + + // Dialog dimensions + const buttonWidth = 28; + const buttonHeight = 10; + const buttonSpacingX = 4; + const buttonSpacingY = 3; + + // Calculate content width to fit 2 columns of buttons with spacing + const totalButtonsWidth = buttonWidth * 2 + buttonSpacingX; + const contentWidth = totalButtonsWidth + 8; // 4 grid units margin on each side + const contentHeight = 62; // Grid units - room for input, status, and 2 rows of buttons + + // Create dialog card + const dialogCard = new PixelCard({ + title: 'Project', + x: 0, + y: 0, + contentWidth, + contentHeight, + renderer, + minContentSize: true + }); + + const contentContainer = dialogCard.getContentContainer(); + const theme = getTheme(); + + // Cleanup function + const cleanup = () => { + createdButtons.forEach(btn => btn.destroy()); + createdButtons.length = 0; + if (textInput) { + textInput.destroy(); + textInput = null; + } + overlay.destroy({ children: true }); + }; + + // --- Add Close Button to Title Bar --- + const btnSize = 8; + const cardTotalWidth = contentWidth + BORDER.total * 2 + GRID.padding * 2; + const btnY = px(GRID.border * 2); + const rightEdge = cardTotalWidth - GRID.border * 2 - 1; + + const closeBtn = createPixelButton({ + size: btnSize, + selectionMode: 'press', + actionMode: 'click', + label: 'X', + onClick: cleanup + }); + createdButtons.push(closeBtn); + closeBtn.container.position.set(px(rightEdge - btnSize), btnY); + dialogCard.container.addChild(closeBtn.container); + + let currentY = px(4); + + // --- Project Name Input --- + const inputWidth = contentWidth - 8; // Leave margin on sides + textInput = createPixelTextInput({ + width: inputWidth, + height: 12, + value: projectName, + placeholder: 'Enter project name...', + label: 'Project Name:', + maxLength: 40 + }); + textInput.container.position.set(px(4), currentY); + contentContainer.addChild(textInput.container); + currentY += px(22); // Label + input + spacing (reduced from 26) + + // --- Status Text --- + if (hasProject) { + const statusLabel = currentFilename + ? `File: ${currentFilename.length > 20 ? '...' + currentFilename.slice(-17) : currentFilename}` + : 'Not saved to file'; + const statusText = createText(statusLabel, theme.text); // Use white text + statusText.position.set(px(4), currentY); + contentContainer.addChild(statusText); + currentY += statusText.height + px(1); + + // Show unsaved changes indicator + if (hasUnsavedChanges) { + const unsavedText = createText('(unsaved changes)', theme.accent); + unsavedText.position.set(px(4), currentY); + contentContainer.addChild(unsavedText); + currentY += unsavedText.height + px(2); + } else { + currentY += px(2); + } + } else { + currentY += px(2); + } + + // --- Button Grid (2 columns) --- + const gridStartX = px(4); // Left margin + + // Row 1: New | Open + const newBtn = createPixelButton({ + width: buttonWidth, + height: buttonHeight, + label: 'New', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + cleanup(); + callbacks.onNew(); + } + }); + createdButtons.push(newBtn); + newBtn.container.position.set(gridStartX, currentY); + contentContainer.addChild(newBtn.container); + + const openBtn = createPixelButton({ + width: buttonWidth, + height: buttonHeight, + label: 'Open', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + cleanup(); + callbacks.onOpen(); + } + }); + createdButtons.push(openBtn); + openBtn.container.position.set(gridStartX + px(buttonWidth + buttonSpacingX), currentY); + contentContainer.addChild(openBtn.container); + currentY += px(buttonHeight + buttonSpacingY); + + // Row 2: Save (centered) + const saveBtn = createPixelButton({ + width: buttonWidth, + height: buttonHeight, + label: 'Save', + selectionMode: 'press', + actionMode: 'click', + onClick: () => { + const name = textInput?.getValue() || projectName; + cleanup(); + callbacks.onSave(name); + } + }); + createdButtons.push(saveBtn); + // Center the save button in the row + const saveBtnX = gridStartX + px((buttonWidth + buttonSpacingX) / 2); + saveBtn.container.position.set(saveBtnX, currentY); + contentContainer.addChild(saveBtn.container); + + // Center dialog on screen + const dialogBounds = dialogCard.container.getBounds(); + dialogCard.container.position.set( + (renderer.width - dialogBounds.width) / 2, + (renderer.height - dialogBounds.height) / 2 + ); + + overlay.addChild(dialogCard.container); + + return { + container: overlay, + close: cleanup, + destroy: cleanup + }; +} diff --git a/packages/pikcell/src/components/save-dialog.ts b/packages/pikcell/src/components/save-dialog.ts new file mode 100644 index 00000000..2eb2946f --- /dev/null +++ b/packages/pikcell/src/components/save-dialog.ts @@ -0,0 +1,52 @@ +/** + * Save Dialog + * Reusable confirmation dialog for save-before-action prompts + */ +import * as PIXI from 'pixi.js'; +import { createPixelDialog, PixelDialogResult } from './pixel-dialog'; + +export interface SaveDialogOptions { + renderer: PIXI.Renderer; + /** Action being performed (e.g., "loading", "creating new project", "importing") */ + action: string; + /** Called when user clicks Save - should save then perform the action */ + onSave: () => void; + /** Called when user clicks Don't Save - should perform the action without saving */ + onDontSave: () => void; +} + +export interface SaveDialogResult extends PixelDialogResult {} + +/** + * Creates a save confirmation dialog + * + * Usage: + * ``` + * const dialog = createSaveDialog({ + * renderer, + * action: 'loading', + * onSave: () => { save(); load(); }, + * onDontSave: () => { load(); } + * }); + * scene.addChild(dialog.container); + * ``` + */ +export function createSaveDialog(options: SaveDialogOptions): SaveDialogResult { + const { renderer, action, onSave, onDontSave } = options; + + return createPixelDialog({ + title: 'Save Current Project?', + message: `You have unsaved changes. Save before ${action}?`, + buttons: [ + { + label: 'Save', + onClick: onSave + }, + { + label: "Don't Save", + onClick: onDontSave + } + ], + renderer + }); +} diff --git a/packages/pikcell/src/components/spritesheet-selector-dialog.ts b/packages/pikcell/src/components/spritesheet-selector-dialog.ts new file mode 100644 index 00000000..c80690b5 --- /dev/null +++ b/packages/pikcell/src/components/spritesheet-selector-dialog.ts @@ -0,0 +1,347 @@ +/** + * Spritesheet Selector Dialog + * Modal dialog for viewing, selecting, and managing spritesheets in a project + */ +import * as PIXI from 'pixi.js'; +import { PixelCard } from './pixel-card'; +import { GRID, px } from '@moxijs/core'; +import { createPixelButton, PixelButtonResult } from './pixel-button'; +import { getTheme, createText } from '../theming/theme'; +import { ComponentResult } from '../interfaces/components'; +import { SpriteSheetInstance } from '../interfaces/managers'; +import { SpriteSheetType } from '../controllers/sprite-sheet-controller'; + +export interface SpritesheetSelectorDialogOptions { + renderer: PIXI.Renderer; + /** Get all sprite sheets */ + getSpritesheets: () => SpriteSheetInstance[]; + /** Get the active spritesheet ID */ + getActiveId: () => string | null; + /** Select a spritesheet by ID */ + onSelect: (id: string) => void; + /** Rename a spritesheet */ + onRename: (id: string, newName: string) => void; + /** Delete a spritesheet */ + onDelete: (id: string) => void; + /** Add a new spritesheet */ + onAddNew: (type: SpriteSheetType) => void; + /** Called when dialog closes */ + onClose?: () => void; +} + +export interface SpritesheetSelectorDialogResult extends ComponentResult { + /** Close the dialog */ + close(): void; + /** Refresh the list (call when sheets change externally) */ + refresh(): void; +} + +interface SheetRowComponents { + container: PIXI.Container; + buttons: PixelButtonResult[]; +} + +/** + * Creates a spritesheet selector dialog + */ +export function createSpritesheetSelectorDialog( + options: SpritesheetSelectorDialogOptions +): SpritesheetSelectorDialogResult { + const { + renderer, + getSpritesheets, + getActiveId, + onSelect, + onRename, + onDelete, + onAddNew, + onClose + } = options; + + const theme = getTheme(); + + // Track created components for cleanup + const createdButtons: PixelButtonResult[] = []; + const sheetRows: SheetRowComponents[] = []; + + // Create overlay container + const overlay = new PIXI.Container(); + + // Semi-transparent backdrop + const backdrop = new PIXI.Graphics(); + backdrop.rect(0, 0, renderer.width, renderer.height); + backdrop.fill({ color: 0x000000, alpha: 0.5 }); + backdrop.eventMode = 'static'; + overlay.addChild(backdrop); + + // Dialog dimensions - width must fit "No spritesheets in project" text + padding + const contentWidth = 130; // Grid units + const rowHeight = 12; // Grid units per sheet row + const maxVisibleRows = 6; + const addButtonHeightForLayout = 10; + + // Calculate content height based on sheets + const sheets = getSpritesheets(); + const rowsCount = Math.min(sheets.length, maxVisibleRows); + const listHeight = Math.max(rowsCount * rowHeight, rowHeight); // At least one row height + const contentHeight = listHeight + addButtonHeightForLayout + 8; // List + add buttons + margins + + // Create dialog card + const dialogCard = new PixelCard({ + title: 'Spritesheets', + x: 0, + y: 0, + contentWidth, + contentHeight, + renderer, + minContentSize: true + }); + + const contentContainer = dialogCard.getContentContainer(); + + // Close button in title bar + const closeBtnSize = 6; + const cardTotalWidth = contentWidth + GRID.border * 6 + GRID.padding * 2; + const closeBtn = createPixelButton({ + size: closeBtnSize, + selectionMode: 'press', + actionMode: 'click', + label: 'X', + onClick: () => { + cleanup(); + onClose?.(); + } + }); + createdButtons.push(closeBtn); + closeBtn.container.position.set( + px(cardTotalWidth - GRID.border * 2 - closeBtnSize - 1), + px(GRID.border * 2) + ); + dialogCard.container.addChild(closeBtn.container); + + // List container + const listContainer = new PIXI.Container(); + listContainer.position.set(px(2), px(2)); + contentContainer.addChild(listContainer); + + /** + * Create a row for a spritesheet + */ + function createSheetRow( + sheet: SpriteSheetInstance, + yOffset: number, + isActive: boolean + ): SheetRowComponents { + const rowContainer = new PIXI.Container(); + rowContainer.position.set(0, yOffset); + const rowButtons: PixelButtonResult[] = []; + + // Active indicator + if (isActive) { + const indicator = new PIXI.Graphics(); + indicator.roundPixels = true; + indicator.circle(px(2), px(rowHeight / 2), px(1.5)); + indicator.fill({ color: theme.accent }); + rowContainer.addChild(indicator); + } + + // Sheet name + const nameText = createText(sheet.name, theme.text); + nameText.position.set(px(6), px(1)); + rowContainer.addChild(nameText); + + // Type label + const config = sheet.sheetCard?.controller.getConfig(); + const typeLabel = config?.type || 'Unknown'; + const typeText = createText(typeLabel, theme.text); + typeText.alpha = 0.7; + typeText.position.set(px(40), px(1)); + rowContainer.addChild(typeText); + + // Action buttons row + const buttonSize = 6; + const buttonSpacing = 2; + let buttonX = px(contentWidth - 4 - (buttonSize * 3 + buttonSpacing * 2)); + + // Select button + const selectBtn = createPixelButton({ + size: buttonSize, + selectionMode: 'press', + actionMode: 'click', + label: 'S', + onClick: () => { + onSelect(sheet.id); + refresh(); + } + }); + rowButtons.push(selectBtn); + createdButtons.push(selectBtn); + selectBtn.container.position.set(buttonX, px(1)); + rowContainer.addChild(selectBtn.container); + buttonX += px(buttonSize + buttonSpacing); + + // Rename button + const renameBtn = createPixelButton({ + size: buttonSize, + selectionMode: 'press', + actionMode: 'click', + label: 'R', + onClick: () => { + // For now, use browser prompt - TODO: custom rename dialog + const newName = prompt('Enter new name:', sheet.name); + if (newName && newName !== sheet.name) { + onRename(sheet.id, newName); + refresh(); + } + } + }); + rowButtons.push(renameBtn); + createdButtons.push(renameBtn); + renameBtn.container.position.set(buttonX, px(1)); + rowContainer.addChild(renameBtn.container); + buttonX += px(buttonSize + buttonSpacing); + + // Delete button + const deleteBtn = createPixelButton({ + size: buttonSize, + selectionMode: 'press', + actionMode: 'click', + label: 'X', + onClick: () => { + // Confirm deletion + if (confirm(`Delete spritesheet "${sheet.name}"?`)) { + onDelete(sheet.id); + refresh(); + } + } + }); + rowButtons.push(deleteBtn); + createdButtons.push(deleteBtn); + deleteBtn.container.position.set(buttonX, px(1)); + rowContainer.addChild(deleteBtn.container); + + // Separator line + const separator = new PIXI.Graphics(); + separator.roundPixels = true; + separator.rect(0, px(rowHeight - 1), px(contentWidth - 4), px(0.5)); + separator.fill({ color: theme.cardBorder, alpha: 0.3 }); + rowContainer.addChild(separator); + + return { container: rowContainer, buttons: rowButtons }; + } + + /** + * Refresh the list display + */ + function refresh(): void { + // Clear existing rows + sheetRows.forEach(row => { + row.buttons.forEach(btn => { + const idx = createdButtons.indexOf(btn); + if (idx !== -1) { + createdButtons.splice(idx, 1); + } + btn.destroy(); + }); + row.container.destroy({ children: true }); + }); + sheetRows.length = 0; + listContainer.removeChildren(); + + // Get current sheets + const currentSheets = getSpritesheets(); + const activeId = getActiveId(); + + if (currentSheets.length === 0) { + // Show "No spritesheets" message + const emptyText = createText('No spritesheets in project', theme.text); + emptyText.alpha = 0.6; + emptyText.position.set(px(4), px(rowHeight / 2 - 2)); + listContainer.addChild(emptyText); + } else { + // Create rows for each sheet + currentSheets.forEach((sheet, index) => { + const yOffset = px(index * rowHeight); + const isActive = sheet.id === activeId; + const row = createSheetRow(sheet, yOffset, isActive); + sheetRows.push(row); + listContainer.addChild(row.container); + }); + } + } + + // Initial list render + refresh(); + + // Add New buttons row + const addButtonsY = px(listHeight + 4); + const addButtonHeight = 10; + const addBtnSpacing = px(4); + + // Add PICO-8 button - let width auto-calculate from label + const addPico8Btn = createPixelButton({ + height: addButtonHeight, + selectionMode: 'press', + actionMode: 'click', + label: '+ PICO-8', + onClick: () => { + onAddNew('PICO-8'); + refresh(); + } + }); + createdButtons.push(addPico8Btn); + contentContainer.addChild(addPico8Btn.container); + + // Add TIC-80 button - let width auto-calculate from label + const addTic80Btn = createPixelButton({ + height: addButtonHeight, + selectionMode: 'press', + actionMode: 'click', + label: '+ TIC-80', + onClick: () => { + onAddNew('TIC-80'); + refresh(); + } + }); + createdButtons.push(addTic80Btn); + contentContainer.addChild(addTic80Btn.container); + + // Position buttons centered together after creation (so we know their actual widths) + const pico8Width = addPico8Btn.container.width; + const tic80Width = addTic80Btn.container.width; + const totalButtonsWidth = pico8Width + tic80Width + addBtnSpacing; + const startX = (px(contentWidth) - totalButtonsWidth) / 2; + + addPico8Btn.container.position.set(startX, addButtonsY); + addTic80Btn.container.position.set(startX + pico8Width + addBtnSpacing, addButtonsY); + + // Center dialog on screen + const dialogBounds = dialogCard.container.getBounds(); + dialogCard.container.position.set( + (renderer.width - dialogBounds.width) / 2, + (renderer.height - dialogBounds.height) / 2 + ); + + overlay.addChild(dialogCard.container); + + // Cleanup function + function cleanup(): void { + createdButtons.forEach(btn => btn.destroy()); + createdButtons.length = 0; + sheetRows.length = 0; + overlay.destroy({ children: true }); + } + + return { + container: overlay, + close: () => { + cleanup(); + onClose?.(); + }, + refresh, + destroy: () => { + cleanup(); + onClose?.(); + } + }; +} diff --git a/packages/pikcell/src/config/card-configs.ts b/packages/pikcell/src/config/card-configs.ts index 969bf11e..b4b22b9c 100644 --- a/packages/pikcell/src/config/card-configs.ts +++ b/packages/pikcell/src/config/card-configs.ts @@ -139,6 +139,31 @@ export const COMPONENT_STYLES = { iconPadding: 3 } as const; +// ============================================================================ +// Animation Preview Card +// ============================================================================ + +export const ANIMATION_PREVIEW_CARD_CONFIG = { + /** Default preview size in grid units (square) */ + defaultPreviewSize: 48, + /** Minimum preview size in grid units */ + minPreviewSize: 24, + /** Maximum preview size in grid units */ + maxPreviewSize: 128, + /** Control button size in grid units (title bar controls) */ + controlButtonSize: 8, + /** Frame thumbnail size in grid units */ + frameThumbnailSize: 10, + /** Add frame button size in grid units (smaller than thumbnails) */ + addButtonSize: 8, + /** Maximum visible frame thumbnails before scrolling */ + maxVisibleFrames: 6, + /** Spacing between frame thumbnails */ + frameSpacing: 1, + /** Title bar control spacing */ + titleControlSpacing: 2 +} as const; + // ============================================================================ // Aggregate export for convenience // ============================================================================ @@ -151,5 +176,6 @@ export const CARD_CONFIGS = { infoBar: INFO_BAR_CONFIG, scale: SCALE_CARD_CONFIG, popup: POPUP_TOOLBAR_CONFIG, - styles: COMPONENT_STYLES + styles: COMPONENT_STYLES, + animationPreview: ANIMATION_PREVIEW_CARD_CONFIG } as const; diff --git a/packages/pikcell/src/config/constants.ts b/packages/pikcell/src/config/constants.ts index c7ef87d5..10fb97ad 100644 --- a/packages/pikcell/src/config/constants.ts +++ b/packages/pikcell/src/config/constants.ts @@ -287,3 +287,38 @@ export const TOOL_CONSTANTS = { select: 'default', }, } as const; + +/** + * Animation preview constants + */ +export const ANIMATION_CONSTANTS = { + /** Default FPS for new animations */ + DEFAULT_FPS: 5, + + /** Minimum FPS */ + MIN_FPS: 1, + + /** Maximum FPS */ + MAX_FPS: 60, + + /** Minimum frame duration in milliseconds (1000ms / MAX_FPS) */ + MIN_FRAME_DURATION_MS: 17, + + /** Maximum frame duration in milliseconds (1000ms / MIN_FPS) */ + MAX_FRAME_DURATION_MS: 1000, + + /** Default preview scale */ + DEFAULT_PREVIEW_SCALE: 8, + + /** Minimum preview scale */ + MIN_PREVIEW_SCALE: 1, + + /** Maximum preview scale */ + MAX_PREVIEW_SCALE: 32, + + /** Maximum frames per sequence */ + MAX_FRAMES_PER_SEQUENCE: 64, + + /** Maximum animation preview windows */ + MAX_PREVIEW_WINDOWS: 8, +} as const; diff --git a/packages/pikcell/src/controllers/animation-controller.ts b/packages/pikcell/src/controllers/animation-controller.ts new file mode 100644 index 00000000..c60f0b62 --- /dev/null +++ b/packages/pikcell/src/controllers/animation-controller.ts @@ -0,0 +1,429 @@ +/** + * Animation Controller + * + * Handles sprite animation playback logic independently of the UI. + * Manages frame timing, sequence progression, and texture generation. + */ +import * as PIXI from 'pixi.js'; +import { SpriteSheetController } from './sprite-sheet-controller'; +import { + AnimationSequenceConfig, + SpriteFrameRef, + createDefaultSequence, + fpsToMs, + msToFps +} from '../interfaces/animation-types'; +import { ANIMATION_CONSTANTS } from '../config/constants'; +import { SPRITE_CONTROLLER_CONFIG } from '../config/controller-configs'; + +export interface AnimationControllerOptions { + spriteSheetController: SpriteSheetController; + sequence?: AnimationSequenceConfig; + scale?: number; + onFrameChange?: (frameIndex: number, texture: PIXI.Texture) => void; + onPlayStateChange?: (isPlaying: boolean) => void; +} + +/** + * Controller for managing sprite animation playback + */ +export class AnimationController { + private spriteSheetController: SpriteSheetController; + private sequence: AnimationSequenceConfig; + private currentFrameIndex: number = 0; + private isPlaying: boolean = false; + private lastFrameTime: number = 0; + private animationFrameId: number | null = null; + private direction: 1 | -1 = 1; // For ping-pong + private scale: number; + + // Event callbacks + private onFrameChange?: (frameIndex: number, texture: PIXI.Texture) => void; + private onPlayStateChange?: (isPlaying: boolean) => void; + + constructor(options: AnimationControllerOptions) { + this.spriteSheetController = options.spriteSheetController; + this.sequence = options.sequence ?? createDefaultSequence(); + this.scale = options.scale ?? ANIMATION_CONSTANTS.DEFAULT_PREVIEW_SCALE; + this.onFrameChange = options.onFrameChange; + this.onPlayStateChange = options.onPlayStateChange; + + // Bind tick to preserve context + this.tick = this.tick.bind(this); + } + + // ============================================================================ + // Playback Controls + // ============================================================================ + + /** + * Start animation playback + */ + public play(): void { + if (this.isPlaying || this.sequence.frames.length === 0) return; + + this.isPlaying = true; + this.lastFrameTime = performance.now(); + this.animationFrameId = requestAnimationFrame(this.tick); + this.onPlayStateChange?.(true); + } + + /** + * Pause animation playback + */ + public pause(): void { + if (!this.isPlaying) return; + + this.isPlaying = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.onPlayStateChange?.(false); + } + + /** + * Toggle play/pause + */ + public togglePlayback(): void { + if (this.isPlaying) { + this.pause(); + } else { + this.play(); + } + } + + /** + * Stop animation and reset to first frame + */ + public stop(): void { + this.pause(); + this.currentFrameIndex = 0; + this.direction = 1; + this.emitFrameChange(); + } + + /** + * Step forward one frame + */ + public stepForward(): void { + this.pause(); + if (this.sequence.frames.length === 0) return; + + this.currentFrameIndex = (this.currentFrameIndex + 1) % this.sequence.frames.length; + this.emitFrameChange(); + } + + /** + * Step backward one frame + */ + public stepBackward(): void { + this.pause(); + if (this.sequence.frames.length === 0) return; + + this.currentFrameIndex = this.currentFrameIndex - 1; + if (this.currentFrameIndex < 0) { + this.currentFrameIndex = this.sequence.frames.length - 1; + } + this.emitFrameChange(); + } + + /** + * Go to a specific frame + */ + public goToFrame(index: number): void { + if (index < 0 || index >= this.sequence.frames.length) return; + this.currentFrameIndex = index; + this.emitFrameChange(); + } + + // ============================================================================ + // Configuration + // ============================================================================ + + /** + * Set the animation sequence + */ + public setSequence(sequence: AnimationSequenceConfig): void { + this.sequence = sequence; + this.currentFrameIndex = 0; + this.direction = 1; + this.emitFrameChange(); + } + + /** + * Get the current sequence + */ + public getSequence(): AnimationSequenceConfig { + return this.sequence; + } + + /** + * Set frame rate in FPS + */ + public setFps(fps: number): void { + const clampedFps = Math.max( + ANIMATION_CONSTANTS.MIN_FPS, + Math.min(ANIMATION_CONSTANTS.MAX_FPS, fps) + ); + this.sequence.frameDurationMs = fpsToMs(clampedFps); + } + + /** + * Get frame rate in FPS + */ + public getFps(): number { + return msToFps(this.sequence.frameDurationMs); + } + + /** + * Set display scale + */ + public setScale(scale: number): void { + this.scale = Math.max( + ANIMATION_CONSTANTS.MIN_PREVIEW_SCALE, + Math.min(ANIMATION_CONSTANTS.MAX_PREVIEW_SCALE, scale) + ); + this.emitFrameChange(); + } + + /** + * Get display scale + */ + public getScale(): number { + return this.scale; + } + + /** + * Set loop mode + */ + public setLoop(loop: boolean): void { + this.sequence.loop = loop; + } + + /** + * Set ping-pong mode + */ + public setPingPong(pingPong: boolean): void { + this.sequence.pingPong = pingPong; + } + + // ============================================================================ + // Frame Management + // ============================================================================ + + /** + * Add a frame to the sequence + */ + public addFrame(frame: SpriteFrameRef): void { + if (this.sequence.frames.length >= ANIMATION_CONSTANTS.MAX_FRAMES_PER_SEQUENCE) return; + this.sequence.frames.push(frame); + this.emitFrameChange(); + } + + /** + * Remove a frame from the sequence + */ + public removeFrame(index: number): void { + if (index < 0 || index >= this.sequence.frames.length) return; + this.sequence.frames.splice(index, 1); + + // Adjust current frame if needed + if (this.currentFrameIndex >= this.sequence.frames.length) { + this.currentFrameIndex = Math.max(0, this.sequence.frames.length - 1); + } + this.emitFrameChange(); + } + + /** + * Reorder frames + */ + public moveFrame(fromIndex: number, toIndex: number): void { + if ( + fromIndex < 0 || + fromIndex >= this.sequence.frames.length || + toIndex < 0 || + toIndex >= this.sequence.frames.length + ) return; + + const frame = this.sequence.frames.splice(fromIndex, 1)[0]; + this.sequence.frames.splice(toIndex, 0, frame); + } + + // ============================================================================ + // State Getters + // ============================================================================ + + /** + * Get current frame index + */ + public getCurrentFrameIndex(): number { + return this.currentFrameIndex; + } + + /** + * Get total frame count + */ + public getTotalFrames(): number { + return this.sequence.frames.length; + } + + /** + * Check if currently playing + */ + public isAnimating(): boolean { + return this.isPlaying; + } + + /** + * Get the sprite sheet controller + */ + public getSpriteSheetController(): SpriteSheetController { + return this.spriteSheetController; + } + + /** + * Get texture for a specific frame + */ + public getFrameTexture(index: number): PIXI.Texture | null { + if (index < 0 || index >= this.sequence.frames.length) return null; + + const frameRef = this.sequence.frames[index]; + return this.generateFrameTexture(frameRef); + } + + /** + * Get texture for current frame + */ + public getCurrentFrameTexture(): PIXI.Texture | null { + return this.getFrameTexture(this.currentFrameIndex); + } + + // ============================================================================ + // Internal Methods + // ============================================================================ + + /** + * Animation loop tick + */ + private tick(): void { + if (!this.isPlaying) return; + + const now = performance.now(); + const currentFrame = this.sequence.frames[this.currentFrameIndex]; + const frameDuration = currentFrame?.duration ?? this.sequence.frameDurationMs; + + if (now - this.lastFrameTime >= frameDuration) { + this.advanceFrame(); + this.lastFrameTime = now; + } + + this.animationFrameId = requestAnimationFrame(this.tick); + } + + /** + * Advance to next frame + */ + private advanceFrame(): void { + const frameCount = this.sequence.frames.length; + if (frameCount === 0) return; + + if (this.sequence.pingPong) { + // Ping-pong mode: bounce back and forth + const nextIndex = this.currentFrameIndex + this.direction; + + if (nextIndex >= frameCount) { + this.direction = -1; + this.currentFrameIndex = frameCount - 2; + if (this.currentFrameIndex < 0) this.currentFrameIndex = 0; + } else if (nextIndex < 0) { + this.direction = 1; + this.currentFrameIndex = 1; + if (this.currentFrameIndex >= frameCount) this.currentFrameIndex = 0; + } else { + this.currentFrameIndex = nextIndex; + } + } else { + // Normal mode + this.currentFrameIndex++; + if (this.currentFrameIndex >= frameCount) { + if (this.sequence.loop) { + this.currentFrameIndex = 0; + } else { + this.currentFrameIndex = frameCount - 1; + this.pause(); + } + } + } + + this.emitFrameChange(); + } + + /** + * Generate texture for a frame reference + * Uses a sub-texture (frame) of the spritesheet texture for direct projection + */ + private generateFrameTexture(frameRef: SpriteFrameRef): PIXI.Texture { + const cellSize = SPRITE_CONTROLLER_CONFIG.cellSize; + + // Get the spritesheet texture directly + const sheetTexture = this.spriteSheetController.getTexture(); + if (!sheetTexture) { + console.warn('Animation: No spritesheet texture available'); + return PIXI.Texture.EMPTY; + } + + if (!sheetTexture.source) { + console.warn('Animation: Spritesheet texture has no source'); + return PIXI.Texture.EMPTY; + } + + // Create a sub-texture that is a "view" into the spritesheet at this cell + // Following tilemap-matic pattern: use Rectangle and texture source + const frame = new PIXI.Rectangle( + frameRef.cellX * cellSize, + frameRef.cellY * cellSize, + cellSize, + cellSize + ); + + const texture = new PIXI.Texture({ + source: sheetTexture.source, + frame: frame + }); + + // Ensure nearest-neighbor scaling for pixel art + texture.source.scaleMode = 'nearest'; + + return texture; + } + + /** + * Emit frame change event + */ + private emitFrameChange(): void { + if (this.sequence.frames.length === 0) return; + + const texture = this.getCurrentFrameTexture(); + if (texture) { + this.onFrameChange?.(this.currentFrameIndex, texture); + } + } + + /** + * Called when sprite sheet pixels change - triggers redraw + * Since we use sub-textures of the spritesheet, they automatically reflect changes + */ + public onSpriteSheetUpdate(): void { + this.emitFrameChange(); + } + + /** + * Clean up resources + */ + public destroy(): void { + this.pause(); + this.onFrameChange = undefined; + this.onPlayStateChange = undefined; + } +} diff --git a/packages/pikcell/src/controllers/sprite-sheet-controller.ts b/packages/pikcell/src/controllers/sprite-sheet-controller.ts index d19c0be7..eaac673d 100644 --- a/packages/pikcell/src/controllers/sprite-sheet-controller.ts +++ b/packages/pikcell/src/controllers/sprite-sheet-controller.ts @@ -50,6 +50,12 @@ export class SpriteSheetController { private hoveredCellX: number = -1; private hoveredCellY: number = -1; private interactionSetup: boolean = false; + // Animation frame highlights - cells that are part of an animation sequence + private animationFrameHighlights: Array<{ cellX: number; cellY: number }> = []; + private animationFrameLabels: PIXI.Text[] = []; + private currentAnimationFrameIndex: number = -1; + // Selection mode - when active, shows overlay to indicate cells can be selected for animation + private selectionModeActive: boolean = false; private isDragging: boolean = false; private dragStartX: number = 0; private dragStartY: number = 0; @@ -328,8 +334,10 @@ export class SpriteSheetController { } // Destroy old texture if exists + // Use destroy(false) to keep the source alive for animation sub-textures + // until they can be regenerated via onSpriteSheetUpdate() if (this.texture) { - this.texture.destroy(true); + this.texture.destroy(false); } // Create texture from canvas with nearest neighbor scaling @@ -338,19 +346,73 @@ export class SpriteSheetController { } /** - * Draw the cell overlay (highlighting selected/hovered cells) + * Draw the cell overlay (highlighting selected/hovered cells and animation frames) */ private drawCellOverlay() { if (!this.cellOverlay || !this.sprite) return; this.cellOverlay.clear(); + // Clean up old animation frame labels + this.animationFrameLabels.forEach(label => label.destroy()); + this.animationFrameLabels = []; + const cellSize = SPRITE_SHEET_CONTROLLER_CONFIG.cellSize * this.scale; // Sprite has anchor (0.5, 0.5), so adjust for centered origin const spriteX = this.sprite.x - (this.config.width * this.scale) / 2; const spriteY = this.sprite.y - (this.config.height * this.scale) / 2; + const spriteWidth = this.config.width * this.scale; + const spriteHeight = this.config.height * this.scale; + + // Draw selection mode overlay (semi-transparent tint over entire sprite) + if (this.selectionModeActive) { + // Draw a cyan/teal tinted overlay to indicate selection mode + this.cellOverlay.rect(spriteX, spriteY, spriteWidth, spriteHeight); + this.cellOverlay.fill({ color: 0x00ffff, alpha: 0.15 }); + + // Draw a border around the sprite to make selection mode obvious + this.cellOverlay.rect(spriteX, spriteY, spriteWidth, spriteHeight); + this.cellOverlay.stroke({ color: 0x00ffff, width: 2, alpha: 0.8 }); + } + + // Draw animation frame highlights first (behind everything) + if (this.animationFrameHighlights.length > 0) { + this.animationFrameHighlights.forEach((frame, index) => { + const x = spriteX + frame.cellX * cellSize; + const y = spriteY + frame.cellY * cellSize; + const isCurrentFrame = index === this.currentAnimationFrameIndex; + + // Draw light gray fill for all frames + this.cellOverlay!.rect(x, y, cellSize, cellSize); + this.cellOverlay!.fill({ color: 0xffffff, alpha: isCurrentFrame ? 0.4 : 0.2 }); + + // Draw highlight border for current frame + if (isCurrentFrame) { + this.cellOverlay!.rect(x + 1, y + 1, cellSize - 2, cellSize - 2); + this.cellOverlay!.stroke({ color: 0x00ff00, width: 2, alpha: 0.9 }); + } + + // Add sequence number label with black outline for readability + const label = new PIXI.Text({ + text: String(index + 1), + style: { + fontSize: Math.max(8, cellSize * 0.4), + fill: isCurrentFrame ? 0x00ff00 : 0xffffff, + fontFamily: 'Arial', + fontWeight: 'bold', + stroke: { color: 0x000000, width: 2 } + } + }); + label.alpha = isCurrentFrame ? 1.0 : 0.85; + label.anchor.set(0.5); + label.x = x + cellSize / 2; + label.y = y + cellSize / 2; + this.cellOverlay!.parent?.addChild(label); + this.animationFrameLabels.push(label); + }); + } - // Draw selected cell first (strong highlight) - behind hover + // Draw selected cell (strong highlight) if (this.selectedCellX >= 0 && this.selectedCellY >= 0) { this.cellOverlay.rect( spriteX + this.selectedCellX * cellSize, @@ -587,6 +649,13 @@ export class SpriteSheetController { return this.pixels.map(row => [...row]); } + /** + * Get the current texture (for creating sub-textures/frames) + */ + public getTexture(): PIXI.Texture | null { + return this.texture; + } + /** * Set all pixel data (for loading) */ @@ -627,6 +696,67 @@ export class SpriteSheetController { this.drawCellOverlay(); } + /** + * Set animation frame highlights to show which cells are part of an animation + * @param frames Array of cell coordinates in sequence order, or null to clear + * @param currentFrameIndex Index of the currently playing/selected frame (-1 for none) + */ + public setAnimationFrameHighlights(frames: Array<{ cellX: number; cellY: number }> | null, currentFrameIndex: number = -1): void { + this.animationFrameHighlights = frames ?? []; + this.currentAnimationFrameIndex = currentFrameIndex; + this.drawCellOverlay(); + } + + /** + * Update just the current animation frame index (for playback updates) + */ + public setCurrentAnimationFrameIndex(index: number): void { + this.currentAnimationFrameIndex = index; + this.drawCellOverlay(); + } + + /** + * Get the current animation frame highlights + */ + public getAnimationFrameHighlights(): Array<{ cellX: number; cellY: number }> { + return this.animationFrameHighlights; + } + + /** + * Set selection mode active state + * When active, shows a colored overlay to indicate cells can be selected for animation + */ + public setSelectionMode(active: boolean): void { + this.selectionModeActive = active; + this.drawCellOverlay(); + } + + /** + * Get current selection mode state + */ + public isSelectionModeActive(): boolean { + return this.selectionModeActive; + } + + /** + * Set a hovered animation frame cell (for external hover events like animation tick bars) + * This will highlight the cell as if it were being hovered + */ + public setHoveredAnimationFrame(cellX: number, cellY: number): void { + this.hoveredCellX = cellX; + this.hoveredCellY = cellY; + this.drawCellOverlay(); + } + + /** + * Clear the hovered animation frame cell + */ + public clearHoveredAnimationFrame(): void { + this.hoveredCellX = -1; + this.hoveredCellY = -1; + this.drawCellOverlay(); + } + /** * Center a specific cell in the viewport * Positions the sprite so the given cell is centered in the content container @@ -692,6 +822,11 @@ export class SpriteSheetController { this.texture.destroy(true); this.texture = null; } + // Clean up animation frame labels + this.animationFrameLabels.forEach(label => label.destroy()); + this.animationFrameLabels = []; + this.animationFrameHighlights = []; + if (this.cellOverlay) { this.cellOverlay.destroy(); this.cellOverlay = null; diff --git a/packages/pikcell/src/handlers/file-drop-handler.ts b/packages/pikcell/src/handlers/file-drop-handler.ts new file mode 100644 index 00000000..d5fcd784 --- /dev/null +++ b/packages/pikcell/src/handlers/file-drop-handler.ts @@ -0,0 +1,153 @@ +/** + * File Drop Handler + * Handles drag-and-drop file operations for PNG spritesheets and .pikcell project files + */ + +export interface FileDropHandlerOptions { + /** The HTML element to attach drop listeners to */ + dropTarget: HTMLElement; + /** Callback when a PNG file is dropped */ + onPngDrop: (file: File) => void; + /** Callback when a .pikcell project file is dropped */ + onProjectDrop: (file: File) => void; + /** Optional callback for drag enter */ + onDragEnter?: () => void; + /** Optional callback for drag leave */ + onDragLeave?: () => void; +} + +export interface FileDropHandlerResult { + /** Remove all event listeners and cleanup */ + destroy: () => void; + /** Check if handler is active */ + isActive: () => boolean; +} + +/** + * Creates a file drop handler for the given target element + */ +export function createFileDropHandler(options: FileDropHandlerOptions): FileDropHandlerResult { + const { dropTarget, onPngDrop, onProjectDrop, onDragEnter, onDragLeave } = options; + + let isActive = true; + let dragCounter = 0; // Track nested drag events + + /** + * Handle dragenter event + */ + function handleDragEnter(e: DragEvent): void { + e.preventDefault(); + e.stopPropagation(); + + dragCounter++; + + if (dragCounter === 1) { + onDragEnter?.(); + } + } + + /** + * Handle dragleave event + */ + function handleDragLeave(e: DragEvent): void { + e.preventDefault(); + e.stopPropagation(); + + dragCounter--; + + if (dragCounter === 0) { + onDragLeave?.(); + } + } + + /** + * Handle dragover event (required to allow drop) + */ + function handleDragOver(e: DragEvent): void { + e.preventDefault(); + e.stopPropagation(); + + // Set the drop effect + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + } + + /** + * Handle drop event + */ + function handleDrop(e: DragEvent): void { + e.preventDefault(); + e.stopPropagation(); + + // Reset drag counter + dragCounter = 0; + onDragLeave?.(); + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; + + // Process first file only + const file = files[0]; + const fileName = file.name.toLowerCase(); + + if (fileName.endsWith('.png')) { + onPngDrop(file); + } else if (fileName.endsWith('.pikcell') || fileName.endsWith('.moxiproject')) { + onProjectDrop(file); + } else { + console.warn(`Unsupported file type: ${file.name}`); + } + } + + // Add event listeners + dropTarget.addEventListener('dragenter', handleDragEnter); + dropTarget.addEventListener('dragleave', handleDragLeave); + dropTarget.addEventListener('dragover', handleDragOver); + dropTarget.addEventListener('drop', handleDrop); + + return { + destroy: () => { + if (!isActive) return; + + dropTarget.removeEventListener('dragenter', handleDragEnter); + dropTarget.removeEventListener('dragleave', handleDragLeave); + dropTarget.removeEventListener('dragover', handleDragOver); + dropTarget.removeEventListener('drop', handleDrop); + + isActive = false; + }, + isActive: () => isActive + }; +} + +/** + * Read a file as text + */ +export async function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)); + reader.readAsText(file); + }); +} + +/** + * Read a file as an Image element + */ +export async function readFileAsImage(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load image: ${file.name}`)); + img.src = reader.result as string; + }; + + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)); + reader.readAsDataURL(file); + }); +} diff --git a/packages/pikcell/src/interfaces/animation-types.ts b/packages/pikcell/src/interfaces/animation-types.ts new file mode 100644 index 00000000..00a52790 --- /dev/null +++ b/packages/pikcell/src/interfaces/animation-types.ts @@ -0,0 +1,90 @@ +/** + * Animation Types + * + * Interfaces for animation preview system + */ + +/** + * Reference to a specific sprite frame in the sheet + */ +export interface SpriteFrameRef { + /** Cell X coordinate (0-based) */ + cellX: number; + /** Cell Y coordinate (0-based) */ + cellY: number; + /** Optional per-frame duration override (ms) */ + duration?: number; +} + +/** + * Configuration for a single animation sequence + */ +export interface AnimationSequenceConfig { + /** Unique ID for this sequence */ + id: string; + /** User-defined name (e.g., "Walk", "Idle") */ + name: string; + /** Ordered list of sprite frames */ + frames: SpriteFrameRef[]; + /** Duration per frame in milliseconds */ + frameDurationMs: number; + /** Whether animation loops */ + loop: boolean; + /** Whether to reverse at end (bounce) */ + pingPong: boolean; +} + +/** + * State for an animation preview window + */ +export interface AnimationPreviewState { + /** Unique preview window ID */ + id: string; + /** Which sprite sheet this animates */ + spriteSheetId: string; + /** Animation sequence configurations (supports multiple rows) */ + sequences: AnimationSequenceConfig[]; + /** Display scale */ + scale: number; + /** Current playback state */ + isPlaying: boolean; + /** Window position */ + position: { x: number; y: number }; + /** Window size in grid units */ + size: { width: number; height: number }; +} + +/** + * Generate a unique ID for sequences/previews + */ +export function generateAnimationId(): string { + return `anim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Create a default animation sequence + */ +export function createDefaultSequence(): AnimationSequenceConfig { + return { + id: generateAnimationId(), + name: 'Animation', + frames: [], + frameDurationMs: 200, // 5 fps + loop: true, + pingPong: false + }; +} + +/** + * Convert FPS to milliseconds per frame + */ +export function fpsToMs(fps: number): number { + return Math.round(1000 / fps); +} + +/** + * Convert milliseconds per frame to FPS + */ +export function msToFps(ms: number): number { + return Math.round(1000 / ms); +} diff --git a/packages/pikcell/src/interfaces/managers.ts b/packages/pikcell/src/interfaces/managers.ts index a60be55d..cd31fdb2 100644 --- a/packages/pikcell/src/interfaces/managers.ts +++ b/packages/pikcell/src/interfaces/managers.ts @@ -22,6 +22,9 @@ export interface SpriteSheetInstance { /** Unique identifier for this sprite sheet */ id: string; + /** User-visible name for this sprite sheet */ + name: string; + /** UI card for the sprite sheet */ sheetCard: SpriteSheetCardResult; @@ -74,13 +77,18 @@ export interface ISpriteSheetManager { /** * Create a new sprite sheet instance */ - create(type: SpriteSheetType, savedId?: string, config?: Partial): SpriteSheetInstance; + create(type: SpriteSheetType, savedId?: string, config?: Partial, name?: string): SpriteSheetInstance; /** * Get a sprite sheet instance by ID */ get(id: string): SpriteSheetInstance | undefined; + /** + * Get a sprite sheet instance by name + */ + getByName(name: string): SpriteSheetInstance | undefined; + /** * Get the currently active sprite sheet */ @@ -111,6 +119,16 @@ export interface ISpriteSheetManager { */ has(id: string): boolean; + /** + * Check if a sprite sheet with the given name exists + */ + hasName(name: string): boolean; + + /** + * Set the name of a sprite sheet + */ + setName(id: string, name: string): boolean; + /** * Get the count of sprite sheets */ @@ -119,7 +137,7 @@ export interface ISpriteSheetManager { /** * Subscribe to sprite sheet events */ - on(event: 'created' | 'removed' | 'activeChanged', handler: (id: string) => void): void; + on(event: 'created' | 'removed' | 'activeChanged' | 'renamed', handler: (id: string) => void): void; } /** diff --git a/packages/pikcell/src/managers/animation-preview-manager.ts b/packages/pikcell/src/managers/animation-preview-manager.ts new file mode 100644 index 00000000..182efdc2 --- /dev/null +++ b/packages/pikcell/src/managers/animation-preview-manager.ts @@ -0,0 +1,342 @@ +/** + * AnimationPreviewManager - Manages animation preview window lifecycle + * + * Handles creation, tracking, and cleanup of animation preview cards. + */ +import * as PIXI from 'pixi.js'; +import { + AnimationPreviewCardResult, + AnimationPreviewCardOptions, + createAnimationPreviewCard +} from '../cards/animation-preview-card'; +import { SpriteSheetController } from '../controllers/sprite-sheet-controller'; +import { + AnimationSequenceConfig, + AnimationPreviewState, + generateAnimationId, + createDefaultSequence +} from '../interfaces/animation-types'; +import { ANIMATION_CONSTANTS } from '../config/constants'; +import { ANIMATION_PREVIEW_CARD_CONFIG } from '../config/card-configs'; + +type EventHandler = (id: string) => void; + +export interface AnimationPreviewInstance { + id: string; + spriteSheetId: string; + card: AnimationPreviewCardResult; +} + +export interface CreatePreviewOptions { + spriteSheetId: string; + spriteSheetController: SpriteSheetController; + renderer: PIXI.Renderer; + scene: PIXI.Container; + x?: number; + y?: number; + /** Initial animation sequences (supports multiple rows) */ + sequences?: AnimationSequenceConfig[]; + onFocus?: (id: string) => void; + onSelectionModeChange?: (previewId: string, isSelecting: boolean) => void; + onShowSettings?: (previewId: string) => void; + onStateChange?: () => void; + /** Called when the current frame changes during playback */ + onFrameChange?: (previewId: string, frameIndex: number) => void; + /** Called when hovering over a frame tick bar */ + onFrameHover?: (previewId: string, frameIndex: number, cellX: number, cellY: number) => void; + /** Called when mouse leaves frame tick bars */ + onFrameHoverEnd?: (previewId: string) => void; + /** Container for tooltips (for z-ordering above other UI) */ + tooltipLayer?: PIXI.Container; +} + +/** + * Manages all animation preview instances in the editor + */ +export class AnimationPreviewManager { + private instances: Map = new Map(); + private eventHandlers: Map> = new Map(); + private nextSpawnOffset: number = 0; + + /** + * Create a new animation preview window + */ + create(options: CreatePreviewOptions): AnimationPreviewInstance | null { + // Check maximum preview windows + if (this.instances.size >= ANIMATION_CONSTANTS.MAX_PREVIEW_WINDOWS) { + console.warn(`Maximum ${ANIMATION_CONSTANTS.MAX_PREVIEW_WINDOWS} animation preview windows reached`); + return null; + } + + const id = generateAnimationId(); + + // Calculate spawn position with offset for cascading effect + const spawnX = (options.x ?? 100) + this.nextSpawnOffset * 20; + const spawnY = (options.y ?? 100) + this.nextSpawnOffset * 20; + this.nextSpawnOffset = (this.nextSpawnOffset + 1) % 5; + + // Create the card + const card = createAnimationPreviewCard({ + id, + x: spawnX, + y: spawnY, + renderer: options.renderer, + spriteSheetController: options.spriteSheetController, + initialSequences: options.sequences, + onClose: () => { + this.remove(id); + }, + onFocus: () => { + options.onFocus?.(id); + }, + onSequenceChange: () => { + options.onStateChange?.(); + }, + onSelectionModeChange: (isSelecting: boolean) => { + options.onSelectionModeChange?.(id, isSelecting); + }, + onShowSettings: () => { + options.onShowSettings?.(id); + }, + onFrameChange: (frameIndex: number) => { + options.onFrameChange?.(id, frameIndex); + }, + onFrameHover: (frameIndex: number, cellX: number, cellY: number) => { + options.onFrameHover?.(id, frameIndex, cellX, cellY); + }, + onFrameHoverEnd: () => { + options.onFrameHoverEnd?.(id); + }, + tooltipLayer: options.tooltipLayer + }); + + // Add to scene + options.scene.addChild(card.container); + + const instance: AnimationPreviewInstance = { + id, + spriteSheetId: options.spriteSheetId, + card + }; + + this.instances.set(id, instance); + this.emit('created', id); + + return instance; + } + + /** + * Get an animation preview instance by ID + */ + get(id: string): AnimationPreviewInstance | undefined { + return this.instances.get(id); + } + + /** + * Get all animation preview instances + */ + getAll(): AnimationPreviewInstance[] { + return Array.from(this.instances.values()); + } + + /** + * Get all preview instances for a specific sprite sheet + */ + getBySheetId(spriteSheetId: string): AnimationPreviewInstance[] { + return this.getAll().filter(instance => instance.spriteSheetId === spriteSheetId); + } + + /** + * Remove an animation preview instance + */ + remove(id: string): void { + const instance = this.instances.get(id); + if (!instance) return; + + // Destroy the card + instance.card.destroy(); + + this.instances.delete(id); + this.emit('removed', id); + } + + /** + * Clear all animation previews + */ + clear(): void { + const ids = Array.from(this.instances.keys()); + ids.forEach(id => this.remove(id)); + this.nextSpawnOffset = 0; + } + + /** + * Clear all previews for a specific sprite sheet + */ + clearBySheetId(spriteSheetId: string): void { + this.getBySheetId(spriteSheetId).forEach(instance => this.remove(instance.id)); + } + + /** + * Check if a preview exists + */ + has(id: string): boolean { + return this.instances.has(id); + } + + /** + * Get the count of preview windows + */ + count(): number { + return this.instances.size; + } + + /** + * Notify all previews that sprite sheet data has changed + */ + onSpriteSheetUpdate(spriteSheetId?: string): void { + const previews = spriteSheetId + ? this.getBySheetId(spriteSheetId) + : this.getAll(); + + previews.forEach(instance => { + instance.card.refresh(); + }); + } + + /** + * Add a frame to a specific preview + */ + addFrameToPreview(previewId: string, cellX: number, cellY: number): void { + const instance = this.get(previewId); + if (!instance) return; + + instance.card.addFrame({ cellX, cellY }); + } + + /** + * Export all preview states for persistence + */ + exportStates(): AnimationPreviewState[] { + return this.getAll().map(instance => { + const sequences = instance.card.getSequences(); + const card = instance.card.card; + const contentSize = card.getContentSize(); + + return { + id: instance.id, + spriteSheetId: instance.spriteSheetId, + sequences, + scale: instance.card.controller.getScale(), + isPlaying: instance.card.controller.isAnimating(), + position: { + x: card.container.x, + y: card.container.y + }, + size: { + width: contentSize.width, + height: contentSize.height + } + }; + }); + } + + /** + * Import preview states (restore from persistence) + */ + importStates( + states: AnimationPreviewState[], + options: { + renderer: PIXI.Renderer; + scene: PIXI.Container; + getSpriteSheetController: (id: string) => SpriteSheetController | null; + onFocus?: (id: string) => void; + onSelectionModeChange?: (previewId: string, isSelecting: boolean) => void; + onShowSettings?: (previewId: string) => void; + onStateChange?: () => void; + onFrameChange?: (previewId: string, frameIndex: number) => void; + onFrameHover?: (previewId: string, frameIndex: number, cellX: number, cellY: number) => void; + onFrameHoverEnd?: (previewId: string) => void; + } + ): void { + // Clear existing previews + this.clear(); + + states.forEach(state => { + const controller = options.getSpriteSheetController(state.spriteSheetId); + if (!controller) { + console.warn(`Cannot restore animation preview: sprite sheet "${state.spriteSheetId}" not found`); + return; + } + + const instance = this.create({ + spriteSheetId: state.spriteSheetId, + spriteSheetController: controller, + renderer: options.renderer, + scene: options.scene, + x: state.position.x, + y: state.position.y, + sequences: state.sequences, + onFocus: options.onFocus, + onSelectionModeChange: options.onSelectionModeChange, + onShowSettings: options.onShowSettings, + onStateChange: options.onStateChange, + onFrameChange: options.onFrameChange, + onFrameHover: options.onFrameHover, + onFrameHoverEnd: options.onFrameHoverEnd + }); + + if (instance) { + // Restore scale + instance.card.controller.setScale(state.scale); + + // Restore playback state + if (state.isPlaying) { + instance.card.play(); + } + } + }); + } + + /** + * Subscribe to preview events + */ + on(event: 'created' | 'removed', handler: EventHandler): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, new Set()); + } + this.eventHandlers.get(event)!.add(handler); + } + + /** + * Unsubscribe from preview events + */ + off(event: 'created' | 'removed', handler: EventHandler): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.delete(handler); + } + } + + /** + * Emit an event to all subscribed handlers + */ + private emit(event: string, id: string): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.forEach(handler => handler(id)); + } + } + + /** + * Get statistics about animation previews + */ + getStats(): { + total: number; + maxAllowed: number; + } { + return { + total: this.instances.size, + maxAllowed: ANIMATION_CONSTANTS.MAX_PREVIEW_WINDOWS + }; + } +} diff --git a/packages/pikcell/src/managers/sprite-sheet-manager.ts b/packages/pikcell/src/managers/sprite-sheet-manager.ts index d314cff1..07ff6bc4 100644 --- a/packages/pikcell/src/managers/sprite-sheet-manager.ts +++ b/packages/pikcell/src/managers/sprite-sheet-manager.ts @@ -24,9 +24,10 @@ export class SpriteSheetManager implements ISpriteSheetManager { * @param type Sprite sheet type (PICO-8, TIC-80, etc.) * @param savedId Optional saved ID to reuse when loading from state * @param config Optional configuration overrides + * @param name Optional name for the sprite sheet * @returns The created sprite sheet instance (or existing one if ID already exists) */ - create(type: SpriteSheetType, savedId?: string, config?: Partial): SpriteSheetInstance { + create(type: SpriteSheetType, savedId?: string, config?: Partial, name?: string): SpriteSheetInstance { const id = savedId || this.generateId(); // If an instance with this ID already exists, return it instead of creating a duplicate @@ -35,10 +36,14 @@ export class SpriteSheetManager implements ISpriteSheetManager { return this.instances.get(id)!; } + // Generate a default name if none provided + const sheetName = name || this.generateDefaultName(type); + // Create the instance with sheetCard as undefined initially // The caller MUST set sheetCard before using the instance - const instance: Partial & { id: string } = { + const instance: Partial & { id: string; name: string } = { id, + name: sheetName, spriteCard: null, spriteController: null, }; @@ -180,7 +185,7 @@ export class SpriteSheetManager implements ISpriteSheetManager { * @param event Event type to subscribe to * @param handler Event handler function */ - on(event: 'created' | 'removed' | 'activeChanged', handler: EventHandler): void { + on(event: 'created' | 'removed' | 'activeChanged' | 'renamed', handler: EventHandler): void { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, new Set()); } @@ -193,7 +198,7 @@ export class SpriteSheetManager implements ISpriteSheetManager { * @param event Event type to unsubscribe from * @param handler Event handler function to remove */ - off(event: 'created' | 'removed' | 'activeChanged', handler: EventHandler): void { + off(event: 'created' | 'removed' | 'activeChanged' | 'renamed', handler: EventHandler): void { const handlers = this.eventHandlers.get(event); if (handlers) { handlers.delete(handler); @@ -259,4 +264,64 @@ export class SpriteSheetManager implements ISpriteSheetManager { type: this.getProjectSpriteSheetType(), }; } + + /** + * Set the name of a sprite sheet + * + * @param id Sprite sheet ID + * @param name New name for the sprite sheet + * @returns True if renamed successfully + */ + setName(id: string, name: string): boolean { + const instance = this.instances.get(id); + if (!instance) return false; + + instance.name = name; + this.emit('renamed', id); + return true; + } + + /** + * Get a sprite sheet by name + * + * @param name Sprite sheet name to find + * @returns The sprite sheet instance or undefined if not found + */ + getByName(name: string): SpriteSheetInstance | undefined { + for (const instance of this.instances.values()) { + if (instance.name === name) { + return instance; + } + } + return undefined; + } + + /** + * Check if a sprite sheet with the given name exists + * + * @param name Sprite sheet name to check + * @returns True if a sprite sheet with this name exists + */ + hasName(name: string): boolean { + return this.getByName(name) !== undefined; + } + + /** + * Generate a default name for a sprite sheet + * + * @param type Sprite sheet type + * @returns A default name like "PICO-8 Sheet 1" + */ + private generateDefaultName(type: SpriteSheetType): string { + let index = 1; + let baseName = type === 'PICO-8' ? 'PICO-8' : 'TIC-80'; + let name = `${baseName} Sheet ${index}`; + + while (this.hasName(name)) { + index++; + name = `${baseName} Sheet ${index}`; + } + + return name; + } } diff --git a/packages/pikcell/src/sprite-editor.ts b/packages/pikcell/src/sprite-editor.ts index 3e017e08..12fb3522 100644 --- a/packages/pikcell/src/sprite-editor.ts +++ b/packages/pikcell/src/sprite-editor.ts @@ -23,6 +23,10 @@ import * as PIXI from 'pixi.js'; import { PixelCard } from './components/pixel-card'; import { createPixelDialog, PixelDialogResult } from './components/pixel-dialog'; +import { createSaveDialog } from './components/save-dialog'; +import { createAnimationSettingsDialog } from './components/animation-settings-dialog'; +import { createProjectDialog } from './components/project-dialog'; +import { createSpritesheetSelectorDialog, SpritesheetSelectorDialogResult } from './components/spritesheet-selector-dialog'; import { createSpriteSheetCard, SPRITESHEET_CONFIGS } from './components/spritesheet-card'; import { SpriteSheetType } from './controllers/sprite-sheet-controller'; import { UIStateManager } from './state/ui-state-manager'; @@ -47,6 +51,7 @@ import { UIManager } from './managers/ui-manager'; import { LayoutManager } from './managers/layout-manager'; import { FileOperationsManager } from './managers/file-operations-manager'; import { SpriteCardFactory } from './managers/sprite-card-factory'; +import { AnimationPreviewManager } from './managers/animation-preview-manager'; import { SpriteSheetInstance } from './interfaces/managers'; // Constants @@ -56,6 +61,18 @@ import { CARD_IDS, getSpriteSheetCardId, getSpriteCardId, isSpriteCard } from '. // Utilities import { calculateCommanderBarHeight } from './utilities/card-utils'; +// Handlers +import { createFileDropHandler, FileDropHandlerResult, readFileAsText } from './handlers/file-drop-handler'; + +// Utilities for image import +import { + importPngAsSpritesheet as importPngUtil, + detectSpriteSheetType, + getPaletteForType, + getDimensionsForType, + resizePixelData +} from './utilities/image-import'; + export interface SpriteEditorOptions { renderer: PIXI.Renderer; scene: PIXI.Container; @@ -74,10 +91,12 @@ export class SpriteEditor { private fileOperationsManager: FileOperationsManager; private spriteCardFactory: SpriteCardFactory; private undoManager: UndoManager; + private animationPreviewManager: AnimationPreviewManager; // Core dependencies private renderer: PIXI.Renderer; private scene: PIXI.Container; + private tooltipLayer: PIXI.Container; // UI Cards (only persistent cards stored here) private commanderBarCard?: CommanderBarCardResult; @@ -121,16 +140,31 @@ export class SpriteEditor { // Track current dialog for cleanup private currentDialog: PixelDialogResult | null = null; + // Track which animation preview is currently in selection mode + private animationSelectionModePreviewId: string | null = null; + private activeAnimationPreviewId: string | null = null; + + // File drop handler for drag-and-drop imports + private fileDropHandler: FileDropHandlerResult | null = null; + + // Drag overlay for visual feedback + private dragOverlay: PIXI.Graphics | null = null; + constructor(options: SpriteEditorOptions) { this.renderer = options.renderer; this.scene = options.scene; + // Create tooltip layer (added to scene later to ensure it's on top) + this.tooltipLayer = new PIXI.Container(); + this.tooltipLayer.label = 'tooltip-layer'; + // Initialize managers this.spriteSheetManager = new SpriteSheetManager(); this.uiManager = new UIManager(this.scene); this.layoutManager = new LayoutManager(this.renderer); this.fileOperationsManager = new FileOperationsManager(); this.undoManager = new UndoManager({ maxHistory: 50 }); + this.animationPreviewManager = new AnimationPreviewManager(); // Initialize sprite card factory this.spriteCardFactory = new SpriteCardFactory({ @@ -153,6 +187,9 @@ export class SpriteEditor { // Setup beforeunload handler to save state when page closes this.setupBeforeUnloadHandler(); + // Setup file drop handler for drag-and-drop imports + this.setupFileDropHandler(); + // Initialize or load project state const loadResult = ProjectStateManager.loadProject(); if (!loadResult.success) { @@ -369,8 +406,8 @@ export class SpriteEditor { } } - // Create instance via manager (with saved ID if available) - const instance = this.spriteSheetManager.create(type, savedState?.id); + // Create instance via manager (with saved ID and name if available) + const instance = this.spriteSheetManager.create(type, savedState?.id, undefined, savedState?.name); // Function to make this sprite sheet active const makeThisSheetActive = () => { @@ -395,6 +432,16 @@ export class SpriteEditor { console.log(`Hovering cell: ${cellX}, ${cellY}`); }, onCellClick: (cellX, cellY) => { + // Check if we're in animation selection mode + if (this.animationSelectionModePreviewId) { + const preview = this.animationPreviewManager.get(this.animationSelectionModePreviewId); + if (preview) { + preview.card.handleCellClick(cellX, cellY); + this.saveProjectState(); + return; + } + } + // Normal behavior: create/update sprite card for this cell createSpriteCardForCell(cellX, cellY); this.saveProjectState(); }, @@ -603,6 +650,309 @@ export class SpriteEditor { } } + /** + * Setup file drop handler for drag-and-drop imports + */ + private setupFileDropHandler(): void { + // Get the canvas element from the renderer + const canvas = this.renderer.canvas as HTMLCanvasElement; + if (!canvas) return; + + this.fileDropHandler = createFileDropHandler({ + dropTarget: canvas, + onPngDrop: (file) => this.handlePngDrop(file), + onProjectDrop: (file) => this.handleProjectDrop(file), + onDragEnter: () => this.showDragOverlay(), + onDragLeave: () => this.hideDragOverlay() + }); + } + + /** + * Show visual overlay when dragging files + */ + private showDragOverlay(): void { + if (this.dragOverlay) return; + + const theme = getTheme(); + this.dragOverlay = new PIXI.Graphics(); + this.dragOverlay.rect(0, 0, this.renderer.width, this.renderer.height); + this.dragOverlay.fill({ color: theme.accent, alpha: 0.2 }); + + // Border + this.dragOverlay.rect(8, 8, this.renderer.width - 16, this.renderer.height - 16); + this.dragOverlay.stroke({ color: theme.accent, width: 4 }); + + this.scene.addChild(this.dragOverlay); + } + + /** + * Hide the drag overlay + */ + private hideDragOverlay(): void { + if (this.dragOverlay) { + this.scene.removeChild(this.dragOverlay); + this.dragOverlay.destroy(); + this.dragOverlay = null; + } + } + + /** + * Handle PNG file drop + */ + private async handlePngDrop(file: File): Promise { + if (this.spriteSheetManager.count() > 0 && this.hasUnsavedChanges) { + const dialog = createSaveDialog({ + renderer: this.renderer, + action: 'importing', + onSave: () => { + this.handleSave(); + setTimeout(() => this.importPngAsSpritesheet(file), PERFORMANCE_CONSTANTS.DIALOG_ACTION_DELAY_MS); + }, + onDontSave: () => this.importPngAsSpritesheet(file) + }); + this.scene.addChild(dialog.container); + } else { + this.importPngAsSpritesheet(file); + } + } + + /** + * Import a PNG file as a new spritesheet + */ + private async importPngAsSpritesheet(file: File): Promise { + try { + // Detect the best sprite sheet type based on image dimensions + const img = await this.loadImageForDetection(file); + const detectedType = detectSpriteSheetType(img.width, img.height); + const palette = getPaletteForType(detectedType); + const targetDims = getDimensionsForType(detectedType); + + // Import the PNG + const imported = await importPngUtil(file, { + palette, + type: detectedType + }); + + // Resize to fit target dimensions if needed + const resizedPixels = resizePixelData(imported.pixels, targetDims.width, targetDims.height); + + // Check for name conflicts + if (this.spriteSheetManager.hasName(imported.name)) { + this.showNameConflictDialog(imported.name, detectedType, resizedPixels); + } else { + this.createSpritesheetFromImport(imported.name, detectedType, resizedPixels); + } + } catch (error) { + console.error('Failed to import PNG:', error); + const dialog = createPixelDialog({ + title: 'Import Error', + message: 'Failed to import PNG file.', + buttons: [{ label: 'OK', onClick: () => {} }], + renderer: this.renderer + }); + this.scene.addChild(dialog.container); + } + } + + /** + * Load image to detect dimensions without full import + */ + private async loadImageForDetection(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = reader.result as string; + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + } + + /** + * Show dialog for name conflict resolution + */ + private showNameConflictDialog( + name: string, + type: SpriteSheetType, + pixels: number[][] + ): void { + const dialog = createPixelDialog({ + title: 'Name Conflict', + message: `A spritesheet named "${name}" already exists.`, + buttons: [ + { + label: 'Rename', + onClick: () => { + const newName = prompt('Enter new name:', `${name} (2)`); + if (newName && !this.spriteSheetManager.hasName(newName)) { + this.createSpritesheetFromImport(newName, type, pixels); + } else if (newName) { + // Still a conflict, try again + this.showNameConflictDialog(newName, type, pixels); + } + } + }, + { + label: 'Replace', + onClick: () => { + // Find and remove existing sheet with this name + const existing = this.spriteSheetManager.getByName(name); + if (existing) { + // Remove existing + if (existing.spriteCard) { + this.scene.removeChild(existing.spriteCard.card.container); + } + if (existing.sheetCard) { + this.scene.removeChild(existing.sheetCard.card.container); + existing.sheetCard.destroy(); + } + this.spriteSheetManager.remove(existing.id); + } + this.createSpritesheetFromImport(name, type, pixels); + } + }, + { + label: 'Cancel', + onClick: () => {} + } + ], + renderer: this.renderer + }); + this.scene.addChild(dialog.container); + } + + /** + * Create a new spritesheet from imported pixel data + */ + private createSpritesheetFromImport( + name: string, + type: SpriteSheetType, + pixels: number[][] + ): void { + // Create the sprite sheet with the imported name + const instance = this.spriteSheetManager.create(type, undefined, undefined, name); + + // Create sprite sheet card + const spriteSheetResult = createSpriteSheetCard({ + config: SPRITESHEET_CONFIGS[type], + renderer: this.renderer, + showGrid: false, + onCellHover: (cellX, cellY) => { + console.log(`Hovering cell: ${cellX}, ${cellY}`); + }, + onCellClick: (cellX, cellY) => { + if (this.animationSelectionModePreviewId) { + const preview = this.animationPreviewManager.get(this.animationSelectionModePreviewId); + if (preview) { + preview.card.handleCellClick(cellX, cellY); + this.saveProjectState(); + return; + } + } + this.spriteCardFactory.createOrUpdate({ instance, cellX, cellY }); + this.saveProjectState(); + }, + onFocus: () => this.activateInstance(instance) + }); + + this.scene.addChild(spriteSheetResult.card.container); + instance.sheetCard = spriteSheetResult; + + // Set the imported pixel data + spriteSheetResult.controller.setPixelData(pixels); + spriteSheetResult.controller.render(spriteSheetResult.card.getContentContainer()); + + // Register with UIManager + const sheetIndex = this.spriteSheetManager.count() - 1; + this.registerCard(getSpriteSheetCardId(sheetIndex), spriteSheetResult.card); + + // Set as active + this.spriteSheetManager.setActive(instance.id); + + // Position the card + this.applyDefaultLayout(); + this.saveProjectState(); + + console.log('🖼️ Imported PNG as spritesheet:', name); + } + + /** + * Handle project file drop + */ + private async handleProjectDrop(file: File): Promise { + if (this.spriteSheetManager.count() > 0 && this.hasUnsavedChanges) { + const dialog = createSaveDialog({ + renderer: this.renderer, + action: 'loading', + onSave: () => { + this.handleSave(); + setTimeout(() => this.loadProjectFromFile(file), PERFORMANCE_CONSTANTS.DIALOG_ACTION_DELAY_MS); + }, + onDontSave: () => this.loadProjectFromFile(file) + }); + this.scene.addChild(dialog.container); + } else { + await this.loadProjectFromFile(file); + } + } + + /** + * Load project from a dropped file + */ + private async loadProjectFromFile(file: File): Promise { + try { + const text = await readFileAsText(file); + const result = ProjectStateManager.importProjectJSON(text); + + if (!result.success || !result.data) { + const dialog = createPixelDialog({ + title: 'Import Error', + message: result.error || 'Failed to load project file.', + buttons: [{ label: 'OK', onClick: () => {} }], + renderer: this.renderer + }); + this.scene.addChild(dialog.container); + return; + } + + // Clear existing work + this.spriteSheetManager.getAll().forEach((instance, index) => { + if (instance.spriteCard) { + this.scene.removeChild(instance.spriteCard.card.container); + this.uiManager.unregisterCard(getSpriteCardId(index)); + } + this.spriteCardFactory.remove(instance); + if (instance.sheetCard) { + instance.sheetCard.controller.destroy(); + this.scene.removeChild(instance.sheetCard.card.container); + this.uiManager.unregisterCard(getSpriteSheetCardId(index)); + } + }); + + this.spriteSheetManager.clear(); + this.projectState = result.data; + this.hasUnsavedChanges = false; + + ProjectStateManager.saveProject(this.projectState); + this.loadProjectState(); + this.centerActiveSheetCell(); + + console.log('📂 Project loaded from dropped file:', file.name); + } catch (error) { + console.error('Failed to load dropped project file:', error); + const dialog = createPixelDialog({ + title: 'Import Error', + message: 'Failed to read project file.', + buttons: [{ label: 'OK', onClick: () => {} }], + renderer: this.renderer + }); + this.scene.addChild(dialog.container); + } + } + /** * Handle undo operation */ @@ -665,6 +1015,8 @@ export class SpriteEditor { instance.spriteCard.redraw(); } instance.sheetCard.controller.render(instance.sheetCard.card.getContentContainer()); + // Update animation previews to reflect pixel changes + this.animationPreviewManager.onSpriteSheetUpdate(instance.id); } /** @@ -680,78 +1032,152 @@ export class SpriteEditor { } /** - * Handle copy operation - copy selected pixels to clipboard + * Handle copy operation - copy selected pixels or cell to clipboard */ private handleCopy(): void { const activeInstance = this.spriteSheetManager.getActive(); - if (!activeInstance?.spriteCard || !activeInstance.spriteController) return; + if (!activeInstance) return; + + // First try: copy pixel selection from sprite editor + if (activeInstance.spriteCard && activeInstance.spriteController) { + const selection = activeInstance.spriteCard.getSelection(); + if (selection) { + const { minX, minY, maxX, maxY } = this.getNormalizedSelection(selection); + const width = maxX - minX + 1; + const height = maxY - minY + 1; + + // Copy pixels from selection + const pixels: number[][] = []; + for (let y = 0; y < height; y++) { + const row: number[] = []; + for (let x = 0; x < width; x++) { + row.push(activeInstance.spriteController.getPixel(minX + x, minY + y)); + } + pixels.push(row); + } - const selection = activeInstance.spriteCard.getSelection(); - if (!selection) return; + this.clipboard = { width, height, pixels }; + console.log(`Copied ${width}x${height} pixels to clipboard`); + return; + } + } - const { minX, minY, maxX, maxY } = this.getNormalizedSelection(selection); - const width = maxX - minX + 1; - const height = maxY - minY + 1; + // Second try: copy cell from spritesheet + if (activeInstance.sheetCard) { + const selectedCell = activeInstance.sheetCard.controller.getSelectedCell(); + if (selectedCell.x >= 0 && selectedCell.y >= 0) { + const cellSize = 8; // 8x8 cell + const startX = selectedCell.x * cellSize; + const startY = selectedCell.y * cellSize; + + // Copy pixels from cell + const pixels: number[][] = []; + for (let y = 0; y < cellSize; y++) { + const row: number[] = []; + for (let x = 0; x < cellSize; x++) { + row.push(activeInstance.sheetCard.controller.getPixel(startX + x, startY + y)); + } + pixels.push(row); + } - // Copy pixels from selection - const pixels: number[][] = []; - for (let y = 0; y < height; y++) { - const row: number[] = []; - for (let x = 0; x < width; x++) { - row.push(activeInstance.spriteController.getPixel(minX + x, minY + y)); + this.clipboard = { width: cellSize, height: cellSize, pixels }; + console.log(`Copied cell (${selectedCell.x}, ${selectedCell.y}) to clipboard`); } - pixels.push(row); } - - this.clipboard = { width, height, pixels }; - console.log(`Copied ${width}x${height} pixels to clipboard`); } /** - * Handle paste operation - paste clipboard at selection or origin + * Handle paste operation - paste clipboard at selection, cell, or origin */ private handlePaste(): void { if (!this.clipboard) return; const activeInstance = this.spriteSheetManager.getActive(); - if (!activeInstance?.spriteCard || !activeInstance.spriteController) return; + if (!activeInstance) return; - const selection = activeInstance.spriteCard.getSelection(); + // First try: paste to pixel selection in sprite editor + if (activeInstance.spriteCard && activeInstance.spriteController) { + const selection = activeInstance.spriteCard.getSelection(); + if (selection) { + const startX = Math.min(selection.x1, selection.x2); + const startY = Math.min(selection.y1, selection.y2); - // Paste at selection top-left, or at origin if no selection - const startX = selection ? Math.min(selection.x1, selection.x2) : 0; - const startY = selection ? Math.min(selection.y1, selection.y2) : 0; + // Begin undo stroke + this.undoManager.beginStroke(activeInstance.id); - // Begin undo stroke - this.undoManager.beginStroke(activeInstance.id); - - // Paste pixels (index 0 is transparent - don't overwrite destination) - for (let y = 0; y < this.clipboard.height; y++) { - for (let x = 0; x < this.clipboard.width; x++) { - const destX = startX + x; - const destY = startY + y; + // Paste pixels (index 0 is transparent - don't overwrite destination) + for (let y = 0; y < this.clipboard.height; y++) { + for (let x = 0; x < this.clipboard.width; x++) { + const destX = startX + x; + const destY = startY + y; - // Skip if out of bounds - if (destX < 0 || destX >= 8 || destY < 0 || destY >= 8) continue; + // Skip if out of bounds (8x8 sprite editor) + if (destX < 0 || destX >= 8 || destY < 0 || destY >= 8) continue; - const newColorIndex = this.clipboard.pixels[y][x]; + const newColorIndex = this.clipboard.pixels[y][x]; - // Skip transparent pixels (index 0) - they don't overwrite - if (newColorIndex === 0) continue; + // Skip transparent pixels (index 0) - they don't overwrite + if (newColorIndex === 0) continue; - const oldColorIndex = activeInstance.spriteController.getPixel(destX, destY); + const oldColorIndex = activeInstance.spriteController.getPixel(destX, destY); - if (oldColorIndex !== newColorIndex) { - this.undoManager.recordPixelChange(destX, destY, oldColorIndex, newColorIndex); - activeInstance.spriteController.setPixel(destX, destY, newColorIndex); + if (oldColorIndex !== newColorIndex) { + this.undoManager.recordPixelChange(destX, destY, oldColorIndex, newColorIndex); + activeInstance.spriteController.setPixel(destX, destY, newColorIndex); + } + } } + + this.undoManager.endStroke(); + this.refreshInstance(activeInstance); + this.saveProjectState(); + console.log(`Pasted ${this.clipboard.width}x${this.clipboard.height} pixels at (${startX}, ${startY})`); + return; } } - this.undoManager.endStroke(); - this.refreshInstance(activeInstance); - this.saveProjectState(); - console.log(`Pasted ${this.clipboard.width}x${this.clipboard.height} pixels at (${startX}, ${startY})`); + // Second try: paste to selected cell on spritesheet + if (activeInstance.sheetCard) { + const selectedCell = activeInstance.sheetCard.controller.getSelectedCell(); + if (selectedCell.x >= 0 && selectedCell.y >= 0) { + const cellSize = 8; + const startX = selectedCell.x * cellSize; + const startY = selectedCell.y * cellSize; + const sheetWidth = activeInstance.sheetCard.controller.getConfig().width; + const sheetHeight = activeInstance.sheetCard.controller.getConfig().height; + + // Begin undo stroke + this.undoManager.beginStroke(activeInstance.id); + + // Paste pixels to the cell + for (let y = 0; y < this.clipboard.height; y++) { + for (let x = 0; x < this.clipboard.width; x++) { + const destX = startX + x; + const destY = startY + y; + + // Skip if out of bounds + if (destX < 0 || destX >= sheetWidth || destY < 0 || destY >= sheetHeight) continue; + + const newColorIndex = this.clipboard.pixels[y][x]; + const oldColorIndex = activeInstance.sheetCard.controller.getPixel(destX, destY); + + if (oldColorIndex !== newColorIndex) { + this.undoManager.recordPixelChange(destX, destY, oldColorIndex, newColorIndex); + activeInstance.sheetCard.controller.setPixel(destX, destY, newColorIndex); + } + } + } + + this.undoManager.endStroke(); + + // Refresh the sheet card display + activeInstance.sheetCard.controller.render(activeInstance.sheetCard.card.getContentContainer()); + // Update animation previews + this.animationPreviewManager.onSpriteSheetUpdate(activeInstance.id); + this.saveProjectState(); + console.log(`Pasted cell to (${selectedCell.x}, ${selectedCell.y})`); + } + } } /** @@ -898,18 +1324,18 @@ export class SpriteEditor { this.scene.removeChildren(); this.renderer.background.color = getTheme().workspace; - // Create commander bar + // Create commander bar - at top-left corner (0, 0) this.commanderBarCard = createCommanderBarCard({ - x: px(GRID.margin), - y: px(GRID.margin), + x: 0, + y: 0, renderer: this.renderer, scene: this.scene, explodeEffect: true, // Enable pixel explosion on PIKCELL bar buttons callbacks: { - onNew: () => this.handleNew(), - onSave: () => this.handleSave(), - onLoad: () => this.handleLoad(), + onProject: () => this.handleProject(), onExport: () => this.handleExport(), + onNewAnimation: () => this.createAnimationPreview(), + onSheets: () => this.showSpritesheetSelector(), onApplyLayout: () => this.applyDefaultLayout(), onSaveLayoutSlot: (slot) => this.handleSaveLayoutSlot(slot), onLoadLayoutSlot: (slot) => this.handleLoadLayoutSlot(slot), @@ -921,12 +1347,12 @@ export class SpriteEditor { this.scene.addChild(this.commanderBarCard.card.container); this.registerCard(CARD_IDS.COMMANDER, this.commanderBarCard.card); - // Create palette card + // Create palette card - below commander bar, at left edge const commanderBarHeight = calculateCommanderBarHeight(); - const topOffset = px(GRID.margin) + commanderBarHeight + px(GRID.gap * 2); + const topOffset = commanderBarHeight + px(1); // 1 grid unit gap below commander this.paletteCard = createPaletteCard({ - x: px(GRID.margin), + x: 0, y: topOffset, renderer: this.renderer, palette: this.currentPalette, @@ -1001,6 +1427,9 @@ export class SpriteEditor { // Update info bar with initial state this.updateInfoBar(); + + // Add tooltip layer last so it's on top of all UI + this.scene.addChild(this.tooltipLayer); } /** @@ -1018,6 +1447,7 @@ export class SpriteEditor { const cell = instance.sheetCard.controller.getSelectedCell(); const state: SpriteSheetState = { id: instance.id, + name: instance.name, type: instance.sheetCard.controller.getConfig().type, showGrid: false, pixels: instance.sheetCard.controller.getPixelData(), @@ -1035,6 +1465,7 @@ export class SpriteEditor { this.projectState.selectedTool = this.currentTool; this.projectState.selectedShape = this.currentShapeType; this.projectState.selectedPalette = this.currentPaletteType; + this.projectState.animationPreviews = this.animationPreviewManager.exportStates(); const saveResult = ProjectStateManager.saveProject(this.projectState); if (!saveResult.success) { @@ -1051,6 +1482,7 @@ export class SpriteEditor { const cell = instance.sheetCard.controller.getSelectedCell(); const state: SpriteSheetState = { id: instance.id, + name: instance.name, type: instance.sheetCard.controller.getConfig().type, showGrid: false, pixels: instance.sheetCard.controller.getPixelData(), @@ -1067,6 +1499,8 @@ export class SpriteEditor { this.projectState.selectedColorIndex = this.selectedColorIndex; this.projectState.selectedTool = this.currentTool; this.projectState.selectedShape = this.currentShapeType; + this.projectState.selectedPalette = this.currentPaletteType; + this.projectState.animationPreviews = this.animationPreviewManager.exportStates(); const saveResult = ProjectStateManager.saveProject(this.projectState); if (!saveResult.success) { @@ -1117,38 +1551,235 @@ export class SpriteEditor { this.createNewSpriteSheet(sheetState.type, sheetState.showGrid, sheetState); }); + + // Restore animation previews + if (this.projectState.animationPreviews && this.projectState.animationPreviews.length > 0) { + this.animationPreviewManager.importStates(this.projectState.animationPreviews, { + renderer: this.renderer, + scene: this.scene, + getSpriteSheetController: (id) => { + const instance = this.spriteSheetManager.get(id); + return instance?.sheetCard?.controller ?? null; + }, + onFocus: (id) => { + const preview = this.animationPreviewManager.get(id); + if (preview) { + // Bring animation card to front (but keep tooltipLayer on top) + this.scene.addChild(preview.card.container); + this.scene.addChild(this.tooltipLayer); + // Highlight animation frames on the spritesheet + this.highlightAnimationFrames(id); + } + }, + onSelectionModeChange: (previewId, isSelecting) => { + if (isSelecting) { + this.animationSelectionModePreviewId = previewId; + } else if (this.animationSelectionModePreviewId === previewId) { + this.animationSelectionModePreviewId = null; + } + // Update spritesheet selection mode overlay + const activeSheet = this.spriteSheetManager.getActive(); + if (activeSheet?.sheetCard) { + activeSheet.sheetCard.controller.setSelectionMode(isSelecting); + } + this.saveProjectState(); + }, + onShowSettings: (previewId) => { + this.showAnimationSettings(previewId); + }, + onStateChange: () => { + // Update spritesheet highlights when frames change + if (this.activeAnimationPreviewId) { + this.highlightAnimationFrames(this.activeAnimationPreviewId); + } + this.saveProjectState(); + }, + onFrameChange: (previewId, frameIndex) => { + // Update spritesheet highlight to show current frame during playback + if (this.activeAnimationPreviewId === previewId) { + this.updateAnimationFrameHighlight(previewId, frameIndex); + } + }, + onFrameHover: (previewId, frameIndex, cellX, cellY) => { + // Highlight the hovered cell on the spritesheet + const preview = this.animationPreviewManager.get(previewId); + if (!preview) return; + const sheetInstance = this.spriteSheetManager.get(preview.spriteSheetId); + if (sheetInstance?.sheetCard?.controller) { + sheetInstance.sheetCard.controller.setHoveredAnimationFrame(cellX, cellY); + } + }, + onFrameHoverEnd: (previewId) => { + // Clear the hover highlight on the spritesheet + const preview = this.animationPreviewManager.get(previewId); + if (!preview) return; + const sheetInstance = this.spriteSheetManager.get(preview.spriteSheetId); + if (sheetInstance?.sheetCard?.controller) { + sheetInstance.sheetCard.controller.clearHoveredAnimationFrame(); + } + } + }); + // Keep tooltipLayer on top after restoring previews + this.scene.addChild(this.tooltipLayer); + } } /** - * Handle "New" button + * Create a new animation preview window */ - private async handleNew(): Promise { - if (this.spriteSheetManager.count() > 0) { + private createAnimationPreview(): void { + const activeSheet = this.spriteSheetManager.getActive(); + if (!activeSheet || !activeSheet.sheetCard) { + // Show dialog if no sprite sheet exists const dialog = createPixelDialog({ - title: 'Save Current Project?', - message: 'You have unsaved work. Save before creating new project?', - buttons: [ - { - label: 'Save', - onClick: () => { - this.handleSave(); - setTimeout(() => this.createNewProject(), PERFORMANCE_CONSTANTS.DIALOG_ACTION_DELAY_MS); - } - }, - { - label: 'Discard', - onClick: () => { - this.createNewProject(); - } - }, - { - label: 'Cancel', - onClick: () => {} - } - ], + title: 'No Sprite Sheet', + message: 'Create a sprite sheet first before creating animations.', + buttons: [{ label: 'OK', onClick: () => {} }], renderer: this.renderer }); this.scene.addChild(dialog.container); + return; + } + + const instance = this.animationPreviewManager.create({ + spriteSheetId: activeSheet.id, + spriteSheetController: activeSheet.sheetCard.controller, + renderer: this.renderer, + scene: this.scene, + tooltipLayer: this.tooltipLayer, + x: 200, + y: 150, + onFocus: (id) => { + // Bring animation card to front (but keep tooltipLayer on top) + const preview = this.animationPreviewManager.get(id); + if (preview) { + this.scene.addChild(preview.card.container); + this.scene.addChild(this.tooltipLayer); + // Highlight animation frames on the spritesheet + this.highlightAnimationFrames(id); + } + }, + onSelectionModeChange: (previewId, isSelecting) => { + if (isSelecting) { + // This preview is now in selection mode + this.animationSelectionModePreviewId = previewId; + } else { + // Exiting selection mode + if (this.animationSelectionModePreviewId === previewId) { + this.animationSelectionModePreviewId = null; + } + } + // Update spritesheet selection mode overlay + const activeSheet = this.spriteSheetManager.getActive(); + if (activeSheet?.sheetCard) { + activeSheet.sheetCard.controller.setSelectionMode(isSelecting); + } + this.saveProjectState(); + }, + onShowSettings: (previewId) => { + this.showAnimationSettings(previewId); + }, + onStateChange: () => { + // Update spritesheet highlights when frames change + if (this.activeAnimationPreviewId) { + this.highlightAnimationFrames(this.activeAnimationPreviewId); + } + this.saveProjectState(); + }, + onFrameChange: (previewId, frameIndex) => { + // Update spritesheet highlight to show current frame during playback + if (this.activeAnimationPreviewId === previewId) { + this.updateAnimationFrameHighlight(previewId, frameIndex); + } + }, + onFrameHover: (previewId, frameIndex, cellX, cellY) => { + // Highlight the hovered cell on the spritesheet + const preview = this.animationPreviewManager.get(previewId); + if (!preview) return; + const sheetInstance = this.spriteSheetManager.get(preview.spriteSheetId); + if (sheetInstance?.sheetCard?.controller) { + sheetInstance.sheetCard.controller.setHoveredAnimationFrame(cellX, cellY); + } + }, + onFrameHoverEnd: (previewId) => { + // Clear the hover highlight on the spritesheet + const preview = this.animationPreviewManager.get(previewId); + if (!preview) return; + const sheetInstance = this.spriteSheetManager.get(preview.spriteSheetId); + if (sheetInstance?.sheetCard?.controller) { + sheetInstance.sheetCard.controller.clearHoveredAnimationFrame(); + } + } + }); + + if (instance) { + console.log(`Created animation preview: ${instance.id}`); + // Keep tooltipLayer on top of all UI + this.scene.addChild(this.tooltipLayer); + this.saveProjectState(); + } + } + + /** + * Show animation settings dialog for a preview + */ + private showAnimationSettings(previewId: string): void { + const preview = this.animationPreviewManager.get(previewId); + if (!preview) return; + + const sequence = preview.card.getSequence(); + + const dialog = createAnimationSettingsDialog({ + sequence, + renderer: this.renderer, + onApply: (updatedSequence) => { + // Update the preview's sequence + preview.card.setSequence(updatedSequence); + this.saveProjectState(); + console.log(`Animation settings updated for ${previewId}`); + }, + onCancel: () => { + console.log('Animation settings cancelled'); + } + }); + + this.scene.addChild(dialog.container); + } + + /** + * Handle "Project" button - shows project management dialog + */ + private handleProject(): void { + const dialog = createProjectDialog({ + renderer: this.renderer, + hasProject: this.spriteSheetManager.count() > 0, + hasUnsavedChanges: this.hasUnsavedChanges, + currentFilename: this.fileOperationsManager.getLastSavedFileName(), + projectName: this.projectState.name, + callbacks: { + onNew: () => this.handleNew(), + onOpen: () => this.handleLoad(), + onSave: (projectName: string) => this.handleSave(projectName) + } + }); + this.scene.addChild(dialog.container); + } + + /** + * Handle "New" button + */ + private async handleNew(): Promise { + if (this.spriteSheetManager.count() > 0 && this.hasUnsavedChanges) { + const dialog = createSaveDialog({ + renderer: this.renderer, + action: 'creating new project', + onSave: () => { + this.handleSave(); + setTimeout(() => this.createNewProject(), PERFORMANCE_CONSTANTS.DIALOG_ACTION_DELAY_MS); + }, + onDontSave: () => this.createNewProject() + }); + this.scene.addChild(dialog.container); } else { this.createNewProject(); } @@ -1184,6 +1815,7 @@ export class SpriteEditor { }); this.spriteSheetManager.clear(); + this.animationPreviewManager.clear(); this.projectState = ProjectStateManager.createEmptyProject(); // Reset palette to default @@ -1238,16 +1870,22 @@ export class SpriteEditor { /** * Handle "Save" button - uses FileOperationsManager */ - private handleSave(): void { + private handleSave(projectName?: string): void { if (this.projectSaveTimer) { clearTimeout(this.projectSaveTimer); } + // Update project name if provided + if (projectName) { + this.projectState.name = projectName; + } + // Update project state this.projectState.spriteSheets = this.spriteSheetManager.getAll().map(instance => { const cell = instance.sheetCard.controller.getSelectedCell(); return { id: instance.id, + name: instance.name, type: instance.sheetCard.controller.getConfig().type, showGrid: false, pixels: instance.sheetCard.controller.getPixelData(), @@ -1261,9 +1899,14 @@ export class SpriteEditor { const activeSheet = this.spriteSheetManager.getActive(); this.projectState.activeSpriteSheetId = activeSheet?.id ?? null; this.projectState.selectedColorIndex = this.selectedColorIndex; + this.projectState.animationPreviews = this.animationPreviewManager.exportStates(); + + // Generate filename from project name + const safeName = (this.projectState.name || 'untitled').toLowerCase().replace(/[^a-z0-9]+/g, '-'); + const filename = `${safeName}.pikcell`; // Delegate to FileOperationsManager - this.fileOperationsManager.saveProject(this.projectState); + this.fileOperationsManager.saveProject(this.projectState, filename); this.hasUnsavedChanges = false; } @@ -1272,22 +1915,14 @@ export class SpriteEditor { */ private async handleLoad(): Promise { if (this.spriteSheetManager.count() > 0 && this.hasUnsavedChanges) { - const dialog = createPixelDialog({ - title: 'Unsaved Changes', - message: 'Loading will replace current project. Continue?', - buttons: [ - { - label: 'Continue', - onClick: async () => { - await this.loadProjectFile(); - } - }, - { - label: 'Cancel', - onClick: () => {} - } - ], - renderer: this.renderer + const dialog = createSaveDialog({ + renderer: this.renderer, + action: 'loading', + onSave: () => { + this.handleSave(); + setTimeout(() => this.loadProjectFile(), PERFORMANCE_CONSTANTS.DIALOG_ACTION_DELAY_MS); + }, + onDontSave: () => this.loadProjectFile() }); this.scene.addChild(dialog.container); } else { @@ -1365,6 +2000,112 @@ export class SpriteEditor { this.fileOperationsManager.exportPNG(activeSheet.sheetCard.controller); } + /** + * Show spritesheet selector dialog + */ + private showSpritesheetSelector(): void { + const dialog = createSpritesheetSelectorDialog({ + renderer: this.renderer, + getSpritesheets: () => this.spriteSheetManager.getAll(), + getActiveId: () => this.spriteSheetManager.getActive()?.id ?? null, + onSelect: (id) => { + const instance = this.spriteSheetManager.get(id); + if (instance) { + this.activateInstance(instance); + } + }, + onRename: (id, newName) => { + this.spriteSheetManager.setName(id, newName); + this.saveProjectState(); + }, + onDelete: (id) => { + // Close sprite card if open for this sheet + const instance = this.spriteSheetManager.get(id); + if (instance?.spriteCard) { + this.scene.removeChild(instance.spriteCard.card.container); + instance.spriteCard.destroy(); + } + + // Remove from scene + if (instance?.sheetCard) { + this.scene.removeChild(instance.sheetCard.card.container); + instance.sheetCard.destroy(); + } + + // Remove from manager + this.spriteSheetManager.remove(id); + this.saveProjectState(); + }, + onAddNew: (type) => { + this.createNewSpriteSheet(type, false); + this.saveProjectState(); + }, + onClose: () => {} + }); + + this.scene.addChild(dialog.container); + } + + /** + * Highlight animation frames on the spritesheet + * Shows which cells are part of an animation sequence with numbers + */ + private highlightAnimationFrames(animationId: string): void { + const preview = this.animationPreviewManager.get(animationId); + if (!preview) return; + + // Get the spritesheet for this animation + const sheetId = preview.spriteSheetId; + const sheetInstance = this.spriteSheetManager.get(sheetId); + if (!sheetInstance?.sheetCard?.controller) return; + + // Get the animation frames and current frame index + const sequence = preview.card.getSequence(); + const frames = sequence.frames.map(f => ({ cellX: f.cellX, cellY: f.cellY })); + const currentFrameIndex = preview.card.getCurrentFrameIndex(); + + // Clear highlights on all other spritesheets + this.spriteSheetManager.getAll().forEach(instance => { + if (instance.id !== sheetId && instance.sheetCard?.controller) { + instance.sheetCard.controller.setAnimationFrameHighlights(null); + } + }); + + // Set highlights on this spritesheet with current frame + sheetInstance.sheetCard.controller.setAnimationFrameHighlights(frames, currentFrameIndex); + + // Store the active animation id for frame updates + this.activeAnimationPreviewId = animationId; + } + + /** + * Clear animation frame highlights from all spritesheets + */ + private clearAnimationFrameHighlights(): void { + this.spriteSheetManager.getAll().forEach(instance => { + if (instance.sheetCard?.controller) { + instance.sheetCard.controller.setAnimationFrameHighlights(null); + } + }); + } + + /** + * Update just the current frame highlight during animation playback + * More efficient than calling highlightAnimationFrames() on every frame + */ + private updateAnimationFrameHighlight(animationId: string, frameIndex: number): void { + const preview = this.animationPreviewManager.get(animationId); + if (!preview) return; + + // Get the spritesheet for this animation + const sheetId = preview.spriteSheetId; + const sheetInstance = this.spriteSheetManager.get(sheetId); + if (!sheetInstance?.sheetCard?.controller) return; + + // Update just the current frame index (don't rebuild entire highlight) + sheetInstance.sheetCard.controller.setCurrentAnimationFrameIndex(frameIndex); + } + /** * Handle saving layout to slot */ @@ -1657,6 +2398,15 @@ export class SpriteEditor { this.beforeUnloadHandler = null; } + // Clean up file drop handler + if (this.fileDropHandler) { + this.fileDropHandler.destroy(); + this.fileDropHandler = null; + } + + // Clean up drag overlay + this.hideDragOverlay(); + // Destroy all sprite sheet controllers to prevent memory leaks this.spriteSheetManager.getAll().forEach(instance => { if (instance.sheetCard) { diff --git a/packages/pikcell/src/state/project-state-manager.ts b/packages/pikcell/src/state/project-state-manager.ts index 7530282e..f184b29e 100644 --- a/packages/pikcell/src/state/project-state-manager.ts +++ b/packages/pikcell/src/state/project-state-manager.ts @@ -7,6 +7,7 @@ import { SpriteSheetType } from '../controllers/sprite-sheet-controller'; import { MainToolType } from '../cards/toolbar-card'; import { ShapeType } from '../theming/tool-icons'; import { PaletteType } from '../theming/palettes'; +import { AnimationPreviewState } from '../interfaces/animation-types'; const PROJECT_STATE_KEY = 'sprite-editor-project'; @@ -24,6 +25,8 @@ export interface OperationResult { */ export interface SpriteSheetState { id: string; + /** User-visible name for the spritesheet */ + name: string; type: SpriteSheetType; showGrid: boolean; pixels: number[][]; // 2D array of color indices @@ -40,12 +43,14 @@ export interface ProjectState { version: number; // For future migrations createdAt: number; // Timestamp modifiedAt: number; // Timestamp + name: string; // Project name spriteSheets: SpriteSheetState[]; activeSpriteSheetId: string | null; selectedColorIndex: number; selectedTool?: MainToolType; // Active tool (pencil, eraser, etc.) selectedShape?: ShapeType; // Active shape for shape tool selectedPalette?: PaletteType; // Active color palette + animationPreviews?: AnimationPreviewState[]; // Animation preview windows } /** @@ -53,22 +58,24 @@ export interface ProjectState { * Handles saving and loading of sprite project data to/from localStorage */ export class ProjectStateManager { - private static readonly CURRENT_VERSION = 1; + private static readonly CURRENT_VERSION = 4; /** * Create a new empty project */ - static createEmptyProject(): ProjectState { + static createEmptyProject(name: string = 'Untitled'): ProjectState { return { version: this.CURRENT_VERSION, createdAt: Date.now(), modifiedAt: Date.now(), + name, spriteSheets: [], activeSpriteSheetId: null, selectedColorIndex: 0, selectedTool: 'pencil', selectedShape: 'square', - selectedPalette: 'pico8' + selectedPalette: 'pico8', + animationPreviews: [] }; } @@ -145,12 +152,37 @@ export class ProjectStateManager { * Migrate old project versions to current version */ private static migrateProject(state: ProjectState): ProjectState { - // For now, just update the version - // In the future, handle migrations between versions here - return { - ...state, - version: this.CURRENT_VERSION - }; + let migrated = { ...state }; + + // v1 -> v2: Add animationPreviews field + if (migrated.version < 2) { + migrated.animationPreviews = []; + migrated.version = 2; + console.log('Migrated project from v1 to v2 (added animation previews)'); + } + + // v2 -> v3: Add name field to sprite sheets + if (migrated.version < 3) { + migrated.spriteSheets = migrated.spriteSheets.map((sheet, index) => { + // Generate a default name based on type and index + const typeName = sheet.type === 'PICO-8' ? 'PICO-8' : 'TIC-80'; + return { + ...sheet, + name: sheet.name || `${typeName} Sheet ${index + 1}` + }; + }); + migrated.version = 3; + console.log('Migrated project from v2 to v3 (added sprite sheet names)'); + } + + // v3 -> v4: Add project name field + if (migrated.version < 4) { + (migrated as ProjectState).name = (migrated as ProjectState).name || 'Untitled'; + migrated.version = 4; + console.log('Migrated project from v3 to v4 (added project name)'); + } + + return migrated; } /** diff --git a/packages/pikcell/src/theming/tool-icons.ts b/packages/pikcell/src/theming/tool-icons.ts index 7e399f3c..9a332da0 100644 --- a/packages/pikcell/src/theming/tool-icons.ts +++ b/packages/pikcell/src/theming/tool-icons.ts @@ -11,6 +11,7 @@ import * as PIXI from 'pixi.js'; export type ToolType = 'pencil' | 'eraser' | 'fill' | 'eyedrop' | 'selection' | 'shape'; export type ShapeType = 'line' | 'circle' | 'circle-filled' | 'square' | 'square-filled'; +export type PlaybackIconType = 'play' | 'pause' | 'loop' | 'pingpong' | 'add-frame' | 'settings'; // Cache for generated textures const textureCache: Map = new Map(); @@ -155,6 +156,78 @@ const SHAPE_ICON_GRIDS: Record = { `.trim() }; +/** + * 8x8 ASCII playback icon definitions (compact for title bar controls) + * # = filled pixel, . = empty + */ +const PLAYBACK_ICON_GRIDS: Record = { + play: ` +..#..... +..##.... +..###... +..####.. +..###... +..##.... +..#..... +........ +`.trim(), + + pause: ` +.##.##.. +.##.##.. +.##.##.. +.##.##.. +.##.##.. +.##.##.. +.##.##.. +........ +`.trim(), + + loop: ` +..####.. +.#....#. +#......# +#..##..# +#..##..# +#......# +.#....#. +..####.. +`.trim(), + + pingpong: ` +........ +#......# +##....## +###..### +###..### +##....## +#......# +........ +`.trim(), + + 'add-frame': ` +...##... +...##... +...##... +######## +######## +...##... +...##... +...##... +`.trim(), + + settings: ` +..#..#.. +.######. +##....## +#..##..# +#..##..# +##....## +.######. +..#..#.. +`.trim() +}; + /** * Parse ASCII grid and render to Graphics at target size * Each cell is rendered as (pixelSize x pixelSize) rectangle @@ -317,3 +390,58 @@ export function createShapeIcon(shape: ShapeType, size: number, color: number, r // No scaling needed - texture is already at target size return sprite; } + +// ============================================================================ +// Playback Icons (8x8 for animation controls) +// ============================================================================ + +/** Playback icon dimensions: 8 wide x 8 tall cells */ +export const PLAYBACK_ICON_WIDTH = 8; +export const PLAYBACK_ICON_HEIGHT = 8; + +/** + * Draw a playback icon directly into a Graphics object. + * Perfect for drawing icons in the same pass as button backgrounds. + */ +export function drawPlaybackIconInto(g: PIXI.Graphics, icon: PlaybackIconType, offsetX: number, offsetY: number, color: number, pixelSize: number): void { + drawAsciiGridInto(g, PLAYBACK_ICON_GRIDS[icon], offsetX, offsetY, color, pixelSize); +} + +/** + * Draws a playback icon from ASCII grid at target size + */ +function drawPlaybackGraphic(icon: PlaybackIconType, color: number, pixelSize: number): PIXI.Graphics { + return renderAsciiGrid(PLAYBACK_ICON_GRIDS[icon], color, pixelSize); +} + +/** + * Creates a playback icon sprite rendered directly at target size + * No sprite scaling needed - texture is generated at final size for pixel-perfect rendering + */ +export function createPlaybackIcon(icon: PlaybackIconType, size: number, color: number, renderer: PIXI.Renderer): PIXI.Sprite { + // Cache key includes size since we render at target size + const pixelSize = Math.floor(size / PLAYBACK_ICON_WIDTH); + const cacheKey = `playback_${icon}_${color}_${pixelSize}`; + + if (!textureCache.has(cacheKey)) { + const graphic = drawPlaybackGraphic(icon, color, pixelSize); + graphic.roundPixels = true; + + const texture = renderer.generateTexture({ + target: graphic, + resolution: 1, + antialias: false + }); + + texture.source.scaleMode = 'nearest'; + graphic.destroy(); + textureCache.set(cacheKey, texture); + } + + const sprite = new PIXI.Sprite(textureCache.get(cacheKey)!); + sprite.roundPixels = true; + sprite.texture.source.scaleMode = 'nearest'; + + // No scaling needed - texture is already at target size + return sprite; +} diff --git a/packages/pikcell/src/utilities/image-import.ts b/packages/pikcell/src/utilities/image-import.ts new file mode 100644 index 00000000..5124fd39 --- /dev/null +++ b/packages/pikcell/src/utilities/image-import.ts @@ -0,0 +1,208 @@ +/** + * Image Import Utility + * Handles importing PNG images as spritesheets + */ +import { readFileAsImage } from '../handlers/file-drop-handler'; +import { SpriteSheetType } from '../controllers/sprite-sheet-controller'; +import { PICO8_PALETTE, TIC80_PALETTE } from '../theming/palettes'; + +export interface ImportedSpritesheet { + /** Extracted name from filename (without extension) */ + name: string; + /** Detected or default sprite sheet type */ + type: SpriteSheetType; + /** Pixel data as 2D array of color indices */ + pixels: number[][]; + /** Original image width */ + width: number; + /** Original image height */ + height: number; +} + +export interface ImageImportOptions { + /** Target palette to map colors to */ + palette: number[]; + /** Target sprite sheet type */ + type: SpriteSheetType; +} + +/** + * Import a PNG file as spritesheet pixel data + * @param file The PNG file to import + * @param options Import options including palette and type + * @returns Imported spritesheet data + */ +export async function importPngAsSpritesheet( + file: File, + options: ImageImportOptions +): Promise { + const { palette, type } = options; + + // Extract name from filename + const name = extractNameFromFilename(file.name); + + // Load the image + const img = await readFileAsImage(file); + + // Get image dimensions + const width = img.width; + const height = img.height; + + // Create canvas and draw image + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get 2D context'); + } + + ctx.drawImage(img, 0, 0); + + // Read pixel data + const imageData = ctx.getImageData(0, 0, width, height); + const data = imageData.data; + + // Convert to color indices + const pixels: number[][] = []; + for (let y = 0; y < height; y++) { + const row: number[] = []; + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const r = data[idx]; + const g = data[idx + 1]; + const b = data[idx + 2]; + const a = data[idx + 3]; + + // If transparent, use color index 0 (usually transparent/black) + if (a < 128) { + row.push(0); + } else { + // Find nearest palette color + const colorIndex = findNearestPaletteColor(r, g, b, palette); + row.push(colorIndex); + } + } + pixels.push(row); + } + + return { + name, + type, + pixels, + width, + height + }; +} + +/** + * Extract a name from a filename (remove extension) + */ +export function extractNameFromFilename(filename: string): string { + // Remove path if present + const baseName = filename.split('/').pop() || filename; + // Remove extension + const dotIndex = baseName.lastIndexOf('.'); + return dotIndex > 0 ? baseName.substring(0, dotIndex) : baseName; +} + +/** + * Find the nearest color in a palette to the given RGB values + * Uses Euclidean distance in RGB color space + */ +export function findNearestPaletteColor( + r: number, + g: number, + b: number, + palette: number[] +): number { + let nearestIndex = 0; + let nearestDistance = Infinity; + + for (let i = 0; i < palette.length; i++) { + const color = palette[i]; + const pr = (color >> 16) & 0xff; + const pg = (color >> 8) & 0xff; + const pb = color & 0xff; + + // Euclidean distance + const distance = Math.sqrt( + Math.pow(r - pr, 2) + + Math.pow(g - pg, 2) + + Math.pow(b - pb, 2) + ); + + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = i; + } + } + + return nearestIndex; +} + +/** + * Detect the best sprite sheet type based on image dimensions + * PICO-8: 128x128 (16x16 cells of 8x8 sprites) + * TIC-80: 256x256 (32x32 cells of 8x8 sprites) + */ +export function detectSpriteSheetType(width: number, height: number): SpriteSheetType { + // Check for exact matches first + if (width === 128 && height === 128) { + return 'PICO-8'; + } + if (width === 256 && height === 256) { + return 'TIC-80'; + } + + // Check for multiples of cell size + if (width <= 128 && height <= 128) { + return 'PICO-8'; + } + + return 'TIC-80'; +} + +/** + * Resize pixel data to fit target dimensions + * If source is smaller, it will be placed in top-left corner + * If source is larger, it will be cropped + */ +export function resizePixelData( + pixels: number[][], + targetWidth: number, + targetHeight: number +): number[][] { + const result: number[][] = []; + + for (let y = 0; y < targetHeight; y++) { + const row: number[] = []; + for (let x = 0; x < targetWidth; x++) { + if (y < pixels.length && x < pixels[y].length) { + row.push(pixels[y][x]); + } else { + row.push(0); // Fill with transparent/black + } + } + result.push(row); + } + + return result; +} + +/** + * Get the palette for a sprite sheet type + */ +export function getPaletteForType(type: SpriteSheetType): number[] { + return type === 'PICO-8' ? PICO8_PALETTE : TIC80_PALETTE; +} + +/** + * Get the dimensions for a sprite sheet type + */ +export function getDimensionsForType(type: SpriteSheetType): { width: number; height: number } { + return type === 'PICO-8' + ? { width: 128, height: 128 } + : { width: 256, height: 256 }; +}