From 5b5848f1dc0069b60882aa023125b50e30d05bbb Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Mon, 1 Jun 2026 22:48:15 +0900 Subject: [PATCH] updateeeed --- README.md | 70 +- README.md.bak | 58 + app.js | 1571 ++++++++++++++--- app.js.bak | 3774 +++++++++++++++++++++++++++++++++++++++++ index.html | 45 +- index.html.bak | 203 +++ js/editor-actions.js | 33 + js/phase2-sync.js | 402 +++++ js/phase2-sync.js.bak | 402 +++++ styles.css | 22 +- styles.css.bak | 901 ++++++++++ 11 files changed, 7271 insertions(+), 210 deletions(-) create mode 100644 README.md.bak create mode 100644 app.js.bak create mode 100644 index.html.bak create mode 100644 js/phase2-sync.js create mode 100644 js/phase2-sync.js.bak create mode 100644 styles.css.bak diff --git a/README.md b/README.md index 4bd97a0..cd5e157 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,69 @@ # Pixel Island Summoner -Local Python rewrite of the original JavaScript browser prototype. +Browser-only prototype for a shared pixel-art island. ## Run -```bash -python app.py -``` +Open `index.html` in a modern browser. No build step or third-party dependency is required. -The app uses Python's built-in Tkinter UI toolkit, so there are no third-party dependencies. +## Current prototype scope -## Notes +- Draw pixel assets in the in-browser editor. +- Save assets to the local library. +- Place static assets and summon dynamic assets onto the island. +- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only. +- Local voting, hiding, remix/copy edit, and import/export tools are included. -- Local data is saved to `pixel_island_save.json`. -- You can draw pixel assets, save them to the library, summon them onto the island, erase placed objects, and export or import JSON save data. -- Mouse wheel zooms the island. In Pan / Inspect mode, drag the island to move the camera. +## Phase 1 changes + +- Asset data is normalized to schema 2+ compatible shape. +- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`. +- Empty depth/light metadata is omitted or normalized. +- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo. + +## Phase 2 changes + +- Local save uses a compact Phase 2 envelope in `localStorage`. +- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data. +- Compact asset bundles can be exported separately from world snapshots. +- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs. +- Local object/asset mutations append small sync events to `eventLog`. +- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches. + +## Phase 3 changes + +- Terrain rendering is chunk-cached instead of using one large terrain canvas. +- Only terrain chunks intersecting the current viewport are drawn. +- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting. +- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices. +- Rendering pauses while the browser tab is hidden and resumes on visibility change. +- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches. +- Data tab reports visible terrain chunks for quick performance inspection. + +## Phase 4 changes + +- Editor schema is now 5. +- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells. +- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it. +- Nudge buttons move the active selection; without a selection they shift the whole drawing. +- Added horizontal/vertical flip, palette-colored outline generation, and clear selection. +- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size. +- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection. + +## Export formats + +- **Export full JSON**: human-readable full local state. +- **Export compact**: compact local state suitable for saving/transferring. +- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully. +- **Export asset bundle**: compact asset blob list for missing-asset transfer. + + +## Phase 4 follow-up adjustments + +- PNG import now chooses the editor canvas size from the image size. Square 8/16/32/64 PNGs import at their native size; other sizes are fit into the nearest supported square canvas. +- Depth is now three-state: high, normal, and low. High brightens sprite pixels; low darkens them. +- Right-clicking the world canvas clears the current map selection instead of moving or placing the selected asset. +- Dynamic sprites now pause intermittently instead of walking continuously. +- Night rendering is darker. +- Object picking now uses sprite-sized, per-opaque-pixel hit testing, so transparent pixels are not clickable. +- The CSS font stack now tries `3x5 MT Pixel Font` first. The font file itself is not bundled. diff --git a/README.md.bak b/README.md.bak new file mode 100644 index 0000000..592176b --- /dev/null +++ b/README.md.bak @@ -0,0 +1,58 @@ +# Pixel Island Summoner + +Browser-only prototype for a shared pixel-art island. + +## Run + +Open `index.html` in a modern browser. No build step or third-party dependency is required. + +## Current prototype scope + +- Draw pixel assets in the in-browser editor. +- Save assets to the local library. +- Place static assets and summon dynamic assets onto the island. +- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only. +- Local voting, hiding, remix/copy edit, and import/export tools are included. + +## Phase 1 changes + +- Asset data is normalized to schema 2+ compatible shape. +- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`. +- Empty depth/light metadata is omitted or normalized. +- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo. + +## Phase 2 changes + +- Local save uses a compact Phase 2 envelope in `localStorage`. +- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data. +- Compact asset bundles can be exported separately from world snapshots. +- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs. +- Local object/asset mutations append small sync events to `eventLog`. +- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches. + +## Phase 3 changes + +- Terrain rendering is chunk-cached instead of using one large terrain canvas. +- Only terrain chunks intersecting the current viewport are drawn. +- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting. +- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices. +- Rendering pauses while the browser tab is hidden and resumes on visibility change. +- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches. +- Data tab reports visible terrain chunks for quick performance inspection. + +## Phase 4 changes + +- Editor schema is now 5. +- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells. +- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it. +- Nudge buttons move the active selection; without a selection they shift the whole drawing. +- Added horizontal/vertical flip, palette-colored outline generation, and clear selection. +- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size. +- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection. + +## Export formats + +- **Export full JSON**: human-readable full local state. +- **Export compact**: compact local state suitable for saving/transferring. +- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully. +- **Export asset bundle**: compact asset blob list for missing-asset transfer. diff --git a/app.js b/app.js index 33389ba..433519f 100644 --- a/app.js +++ b/app.js @@ -4,7 +4,7 @@ console.info('Pixel Island Summoner loaded'); const STORAGE_KEY = 'pixel-island-summoner:reset'; - const SAVE_SCHEMA = 1; + const SAVE_SCHEMA = 6; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -16,10 +16,18 @@ const DYNAMIC_SCALE = 1; const MAX_ZOOM = 3.2; const MIN_ZOOM = 0.45; + const TERRAIN_CHUNK_SIZE = 16; + const VIEW_CULL_MARGIN = 192; + const DYNAMIC_LOGIC_STEP_MS = 1000 / 15; + const MAX_DYNAMIC_STEPS_PER_FRAME = 3; const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const PALETTE = buildPalette(); const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color])); + const MODULES = window.PixelIslandModules || {}; + const EditorActions = MODULES.EditorActions || null; + const Phase2Sync = MODULES.Phase2Sync || null; + const EDITOR_HISTORY_LIMIT = 80; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); @@ -80,13 +88,14 @@ roleHint: $('roleHint'), sideSwitcher: $('sideSwitcher'), editRight: $('editRight'), editLeft: $('editLeft'), - paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), - toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), + paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), + toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), - exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox') + exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'), + exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats') }; const ctx = els.canvas.getContext('2d', { alpha: false }); @@ -106,7 +115,7 @@ let view = { x: 0, y: 0, zoom: 1 }; let pointer = { down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0, - dragging: false, downTime: 0 + dragging: false, downTime: 0, button: 0 }; let hoverTile = null; let dynamicRuntime = []; @@ -123,6 +132,7 @@ let selectedColorCode = 'a'; let advancedDraw = false; let depthPixels = blankPixels(8).map(() => 0); + let depthPaintMode = 1; let isPainting = false; let staticKind = 'nature'; let dynamicKind = 'human'; @@ -137,11 +147,24 @@ let confettiParticles = []; let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; let shadowCanvasCache = new WeakMap(); + let animationFrameId = 0; + let dynamicLogicRemainder = 0; + let worldIndex = makeEmptyWorldIndex(); let selectedObject = null; let libraryFilter = 'all'; let renderPhase = null; let editorView = { zoom: 1, x: 0, y: 0 }; let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 }; + let editorHistory = []; + let editorFuture = []; + let editorGestureSnapshot = null; + let editorGestureChanged = false; + let suppressNextPaintClick = false; + let suppressMousePaintUntil = 0; + let editorSelection = null; + let selectionGesture = null; + let shapeGesture = null; + let shapePreview = null; function bootstrap() { resizeCanvas(); @@ -151,10 +174,13 @@ renderPalette(); hydrateAuthorUI(); refreshCategoryUI(); - setupEditor(8, blankPixels(8), blankPixels(8)); + setupEditor(8, blankPixels(8), null); + clearEditorHistory(); renderLibrary(); updateSelectedLabel(); - requestAnimationFrame(tick); + rebuildWorldIndex(); + updateSyncStats(); + scheduleFrame(); } function wireUI() { @@ -190,15 +216,20 @@ els.canvas.addEventListener('wheel', onWorldWheel, { passive: false }); els.canvas.addEventListener('contextmenu', (event) => event.preventDefault()); document.addEventListener('contextmenu', onDocumentContextMenu); + document.addEventListener('visibilitychange', onVisibilityChange); els.assetSize.addEventListener('change', () => { + const oldSize = editorSize; const nextSize = Number(els.assetSize.value); - setupEditor(nextSize, resizePixels(getActiveEditorPixels(), editorSize, nextSize), resizePixels(editorLeftPixels, editorSize, nextSize)); - doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; - depthPixels = resizeDepthPixels(depthPixels, editorSize, nextSize); - lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); - resetEditorView(); - drawEditor(); + commitEditorMutation(() => { + const resizedRight = resizePixels(editorPixels, oldSize, nextSize); + const resizedDepth = resizeDepthPixels(depthPixels, oldSize, nextSize); + setupEditor(nextSize, resizedRight, null, resizedDepth); + doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; + lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); + resetEditorView(); + return true; + }); updateSettingsSummary(); }); @@ -223,25 +254,53 @@ els.toolBrush.addEventListener('click', () => setPaintTool('brush')); els.toolErase.addEventListener('click', () => setPaintTool('erase')); + els.toolFill?.addEventListener('click', () => setPaintTool('fill')); + els.toolPick?.addEventListener('click', () => setPaintTool('pick')); + els.toolLine?.addEventListener('click', () => setPaintTool('line')); + els.toolRect?.addEventListener('click', () => setPaintTool('rect')); + els.toolSelect?.addEventListener('click', () => setPaintTool('select')); els.toolLight.addEventListener('click', () => setPaintTool('light')); els.toolDoor.addEventListener('click', () => setPaintTool('door')); els.toolDepth?.addEventListener('click', () => setPaintTool('depth')); + els.depthHigh?.addEventListener('click', () => setDepthPaintMode(1)); + els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1)); + els.depthClear?.addEventListener('click', () => setDepthPaintMode(0)); els.toggleAdvanced?.addEventListener('click', () => { advancedDraw = !advancedDraw; toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); + toggleHidden(els.depthHigh, !advancedDraw); + toggleHidden(els.depthLow, !advancedDraw); + toggleHidden(els.depthClear, !advancedDraw); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced.classList.toggle('active', advancedDraw); if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush'); drawEditor(); }); els.clearPaint.addEventListener('click', () => { - if (editingSide === 'left') editorLeftPixels = blankPixels(editorSize); - else editorPixels = blankPixels(editorSize); - depthPixels = blankPixels(editorSize).map(() => 0); - lightPixels = []; - drawEditor(); + commitEditorMutation(() => { + editorPixels = blankPixels(editorSize); + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + depthPixels = blankPixels(editorSize).map(() => 0); + lightPixels = []; + editorSelection = null; + return true; + }); }); + els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); + els.flipVertical?.addEventListener('click', flipEditorVertical); + els.outlinePaint?.addEventListener('click', applyEditorOutline); + els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); }); + els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0)); + els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0)); + els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1)); + els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1)); + els.exportPng?.addEventListener('click', exportEditorPng); + els.importPng?.addEventListener('click', () => els.pngImportInput?.click()); + els.pngImportInput?.addEventListener('change', importEditorPng); + els.undoPaint?.addEventListener('click', undoEditor); + els.redoPaint?.addEventListener('click', redoEditor); + window.addEventListener('keydown', onEditorKeyDown); els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); @@ -256,7 +315,7 @@ window.addEventListener('mouseup', onPaintMouseUp); els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false }); els.paintCanvas.addEventListener('click', onPaintClick); - window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; }); + window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; finishEditorGesture(); }); els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault()); els.saveAsset.addEventListener('click', saveAssetFromEditor); @@ -264,6 +323,9 @@ els.newAsset.addEventListener('click', newAsset); els.exportData.addEventListener('click', exportData); els.importData.addEventListener('click', importData); + els.exportCompact?.addEventListener('click', exportCompactData); + els.exportSnapshot?.addEventListener('click', exportSnapshotData); + els.exportAssetBundle?.addEventListener('click', exportAssetBundleData); els.resetAll.addEventListener('click', resetAll); els.showHiddenAssets?.addEventListener('click', () => { els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden; @@ -373,23 +435,38 @@ const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`; } else { - const hasLeft = hasAnyPixel(editorLeftPixels); - els.settingsSummary.textContent = `${cap(role)} / ▶ Right first / ◀ Left ${hasLeft ? 'ready' : 'auto-mirror suggested'}.`; + els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`; } } function setPaintTool(tool) { paintTool = tool; - [els.toolBrush, els.toolErase, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); - ({ brush: els.toolBrush, erase: els.toolErase, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); + [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); + ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); const hints = { brush: 'Draw pixels with the selected palette color.', erase: 'Erase pixels. Erasing also clears light markers on that cell.', + fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.', + pick: 'Pick a color from the canvas and return to Draw.', + line: 'Drag to draw a straight line. Right-click erases along the line.', + rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', + select: 'Drag a rectangle to select pixels, then drag or nudge the selection.', light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', door: 'Click one pixel to mark a building door. Humans will enter near this point.', - depth: 'Advanced: paint depth values onto pixels. Shift/right-click erases depth.' + depth: 'Advanced: paint high or low depth. Use High/Low/Clear buttons; Shift clears.' }; - els.editHint.textContent = hints[tool]; + els.editHint.textContent = hints[tool] || hints.brush; + [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); + if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); + } + + function setDepthPaintMode(mode) { + depthPaintMode = mode === -1 ? -1 : mode === 0 ? 0 : 1; + [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); + ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); + if (paintTool !== 'depth') setPaintTool('depth'); + const label = depthPaintMode > 0 ? 'high' : depthPaintMode < 0 ? 'low' : 'clear'; + if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Left-drag paints this depth value.`; } function setEditingSide(side) { @@ -407,6 +484,12 @@ function onWorldPointerDown(event) { event.preventDefault(); + if (event.button === 2) { + clearWorldSelection(true); + pointer.down = false; + pointer.id = null; + return; + } els.canvas.setPointerCapture(event.pointerId); pointer = { down: true, @@ -416,7 +499,8 @@ lastX: event.clientX, lastY: event.clientY, dragging: false, - downTime: performance.now() + downTime: performance.now(), + button: event.button || 0 }; els.canvas.classList.add('dragging'); } @@ -523,43 +607,126 @@ }; } + function tileKey(x, y) { + return `${x},${y}`; + } - function getDrawableItems(time = performance.now()) { + function makeEmptyWorldIndex() { + return { placedByTile: new Map(), dynamicByHomeTile: new Map() }; + } + + function pushIndexBucket(map, key, item) { + const bucket = map.get(key); + if (bucket) bucket.push(item); + else map.set(key, [item]); + } + + function rebuildWorldIndex() { + const next = makeEmptyWorldIndex(); + for (const placed of state.placed || []) { + pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed); + } + for (const summon of state.dynamicSummons || []) { + pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon); + } + worldIndex = next; + } + + function getPlacedAtTile(x, y) { + return worldIndex.placedByTile.get(tileKey(x, y)) || []; + } + + function getDynamicHomesAtTile(x, y) { + return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || []; + } + + + function getDrawableItems(time = performance.now(), viewport = null) { + const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); const items = []; for (const placed of state.placed) { const asset = findAsset(placed.assetId); if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue; - items.push({ kind: 'static', asset, x: placed.x + .5, y: placed.y + .5, source: placed }); + const itemX = placed.x + .5; + const itemY = placed.y + .5; + if (!isApproxVisible(asset, itemX, itemY, rect)) continue; + items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed }); } for (const runtime of dynamicRuntime) { const asset = findAsset(runtime.assetId); if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue; if (time < runtime.hiddenUntil) continue; + if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); } - items.sort((a, b) => (a.x + a.y) - (b.x + b.y) || a.y - b.y); + items.sort(drawOrderCompare); return items; } + function drawOrderCompare(a, b) { + return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || '')); + } + + function getViewportWorldRect(margin = 0) { + const invZoom = 1 / Math.max(0.0001, view.zoom); + return { + left: -view.x * invZoom - margin, + top: -view.y * invZoom - margin, + right: (cw - view.x) * invZoom + margin, + bottom: (ch - view.y) * invZoom + margin + }; + } + + function isApproxVisible(asset, x, y, rect) { + if (!rect) return true; + const pos = tileToWorld(x, y); + if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y); + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const w = asset.size * scale + 96; + const h = asset.size * scale + 128; + return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom; + } + + function pickObjectAtScreen(screenX, screenY, time = performance.now()) { const worldX = (screenX - view.x) / view.zoom; const worldY = (screenY - view.y) / view.zoom; - const items = getDrawableItems(time); + const items = getDrawableItems(time, getViewportWorldRect(256)); for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; const info = getSpriteDrawInfo(item, time, false); - const margin = item.kind === 'dynamic' ? 18 : 8; - const left = info.drawX - margin; - const top = info.drawY - margin; - const right = info.drawX + info.sprite.width + margin; - const bottom = info.drawY + info.sprite.height + margin; - if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) { + if (isSpritePixelHit(item.asset, info, worldX, worldY)) { return { kind: item.kind, id: item.source.id, assetId: item.asset.id }; } } return null; } + function isSpritePixelHit(asset, info, worldX, worldY) { + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + let localX; + let localY; + if (info.angle) { + const pivotX = info.drawX + info.sprite.width / 2; + const pivotY = info.drawY + info.sprite.height * 0.8; + const dx = worldX - pivotX; + const dy = worldY - pivotY; + const cos = Math.cos(-info.angle); + const sin = Math.sin(-info.angle); + localX = dx * cos - dy * sin + info.sprite.width / 2; + localY = dx * sin + dy * cos + info.sprite.height * 0.8; + } else { + localX = worldX - info.drawX; + localY = worldY - info.drawY; + } + if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false; + const px = Math.floor(localX / scale); + const py = Math.floor(localY / scale); + if (px < 0 || py < 0 || px >= asset.size || py >= asset.size) return false; + const pixels = getAssetPixels(asset, info.side || 'right'); + return Boolean(pixels[py * asset.size + px]); + } + function updateTileInfo() { if (!els.tileInfo) return; if (!hoverTile) { @@ -567,15 +734,21 @@ return; } const tile = world.get(hoverTile.x, hoverTile.y); - const staticCount = state.placed.filter((p) => p.x === hoverTile.x && p.y === hoverTile.y).length; - const dynamicCount = state.dynamicSummons.filter((p) => Math.round(p.homeX) === hoverTile.x && Math.round(p.homeY) === hoverTile.y).length; + const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length; + const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length; els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`; } + function clearWorldSelection(announce = false) { + selectedObject = null; + updateSelectionBubble(performance.now()); + if (announce) toast('Selection cleared.'); + } + function inspectAt(x, y) { const tile = world.get(x, y); - const staticObjects = state.placed.filter((p) => p.x === x && p.y === y); - const dynamicObjects = state.dynamicSummons.filter((p) => Math.round(p.homeX) === x && Math.round(p.homeY) === y); + const staticObjects = getPlacedAtTile(x, y); + const dynamicObjects = getDynamicHomesAtTile(x, y); const picked = [...dynamicObjects.map((p) => ({ ...p, objectKind: 'dynamic' })), ...staticObjects.map((p) => ({ ...p, objectKind: 'static' }))].at(-1); if (picked) { selectWorldObject(picked.objectKind, picked.id, picked.assetId, performance.now()); @@ -615,11 +788,11 @@ if (asset.category === 'static') { const existing = state.placed.find((p) => p.assetId === asset.id); if (existing) { - existing.x = x; existing.y = y; existing.placedAt = Date.now(); + existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1; selectWorldObject('static', existing.id, asset.id, performance.now()); toast(`${asset.name} moved.`); } else { - const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now() }; + const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 }; state.placed.push(placed); selectWorldObject('static', placed.id, asset.id, performance.now()); toast(`${asset.name} placed.`); @@ -627,10 +800,10 @@ } else { const existing = state.dynamicSummons.find((p) => p.assetId === asset.id); if (existing) { - existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); + existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1; toast(`${asset.name} moved.`); } else { - const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now() }; + const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), version: 1 }; state.dynamicSummons.push(summon); toast(`${asset.name} summoned.`); } @@ -638,6 +811,11 @@ const found = state.dynamicSummons.find((p) => p.assetId === asset.id); if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now()); } + const object = asset.category === 'static' + ? state.placed.find((p) => p.assetId === asset.id) + : state.dynamicSummons.find((p) => p.assetId === asset.id); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object)); spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); saveState(); } @@ -662,22 +840,32 @@ } function eraseAt(x, y) { - for (let i = state.placed.length - 1; i >= 0; i--) { - const item = state.placed[i]; - if (item.x === x && item.y === y) { + const staticObjects = getPlacedAtTile(x, y); + const staticTarget = staticObjects.at(-1); + if (staticTarget) { + const index = state.placed.findIndex((p) => p.id === staticTarget.id); + if (index >= 0) { + const item = state.placed[index]; const asset = findAsset(item.assetId); - state.placed.splice(i, 1); + state.placed.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id)); if (selectedObject?.id === item.id) selectedObject = null; saveState(); toast(`${asset?.name ?? 'Object'} removed.`); return; } } - for (let i = state.dynamicSummons.length - 1; i >= 0; i--) { - const item = state.dynamicSummons[i]; - if (Math.round(item.homeX) === x && Math.round(item.homeY) === y) { + const dynamicObjects = getDynamicHomesAtTile(x, y); + const dynamicTarget = dynamicObjects.at(-1); + if (dynamicTarget) { + const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id); + if (index >= 0) { + const item = state.dynamicSummons[index]; const asset = findAsset(item.assetId); - state.dynamicSummons.splice(i, 1); + state.dynamicSummons.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id)); if (selectedObject?.id === item.id) selectedObject = null; hydrateRuntime(); saveState(); @@ -689,12 +877,15 @@ } + function eraseObject(kind, objectId) { if (kind === 'static') { const index = state.placed.findIndex((p) => p.id === objectId); if (index >= 0) { const asset = findAsset(state.placed[index].assetId); state.placed.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId)); if (selectedObject?.id === objectId) selectedObject = null; saveState(); updateSelectionBubble(performance.now()); @@ -707,6 +898,8 @@ if (index >= 0) { const asset = findAsset(state.dynamicSummons[index].assetId); state.dynamicSummons.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId)); if (selectedObject?.id === objectId) selectedObject = null; hydrateRuntime(); saveState(); @@ -716,16 +909,27 @@ } } + function usesRightButtonAsPaint() { + return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; + } + function onPaintPointerDown(event) { event.preventDefault(); els.paintCanvas.setPointerCapture?.(event.pointerId); lastPaintedKey = ''; - const isPan = event.button === 1 || event.button === 2; - editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY }; + const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); + editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY, button: event.button || 0 }; isPainting = !isPan; mousePaint.active = false; mousePaint.panning = false; - if (isPainting) paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); + if (isPainting) { + suppressNextPaintClick = true; + suppressMousePaintUntil = performance.now() + 350; + if (handleEditorSpecialPointerDown(event)) return; + beginEditorGesture(); + paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); + if (paintTool === 'fill' || paintTool === 'pick') { isPainting = false; finishEditorGesture(); } + } } function onPaintPointerMove(event) { @@ -743,23 +947,37 @@ drawEditor(); return; } + if (shapeGesture || selectionGesture) { + event.preventDefault(); + handleEditorSpecialPointerMove(event); + return; + } if (!isPainting) return; - if (event.buttons !== undefined && (event.buttons & 1) === 0) return; + if (event.buttons !== undefined) { + const requiredButton = editorPointer?.button === 2 ? 2 : 1; + if ((event.buttons & requiredButton) === 0) return; + } event.preventDefault(); paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); } function onPaintPointerUp(event) { if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false; + if (shapeGesture || selectionGesture) handleEditorSpecialPointerUp(event); isPainting = false; lastPaintedKey = ''; + finishEditorGesture(); } function onPaintClick(event) { if (event.button !== 0) return; + if (suppressNextPaintClick) { suppressNextPaintClick = false; return; } event.preventDefault(); lastPaintedKey = ''; + if (paintTool === 'line' || paintTool === 'rect' || paintTool === 'select') return; + beginEditorGesture(); paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); + finishEditorGesture(); } function onPaintWheel(event) { @@ -784,9 +1002,14 @@ if (event.button !== 0 && event.button !== 1 && event.button !== 2) return; event.preventDefault(); lastPaintedKey = ''; - const isPan = event.button === 1 || event.button === 2; + const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button }; - if (mousePaint.active) paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey); + if (performance.now() < suppressMousePaintUntil) return; + if (mousePaint.active) { + beginEditorGesture(); + paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey); + if (paintTool === 'fill' || paintTool === 'pick') { mousePaint.active = false; finishEditorGesture(); } + } } function onPaintMouseMove(event) { @@ -811,6 +1034,7 @@ mousePaint.active = false; mousePaint.panning = false; lastPaintedKey = ''; + finishEditorGesture(); } function paintAtEvent(event) { @@ -822,30 +1046,81 @@ const x = Math.floor(local.x); const y = Math.floor(local.y); if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return; - const key = `${x},${y},${paintTool},${shiftKey}`; - if (key === lastPaintedKey) return; + const key = `${x},${y},${paintTool},${button},${shiftKey}`; + if (key === lastPaintedKey && paintTool !== 'fill') return; lastPaintedKey = key; - const pixels = getActiveEditorPixels(); - const index = y * editorSize + x; - if (paintTool === 'brush') { - pixels[index] = selectedColorCode; + const point = displayCellToCanonical(x, y); + const index = point.y * editorSize + point.x; + let changed = false; + + if (paintTool === 'pick') { + const picked = editorPixels[index]; + if (picked) selectPaletteCode(picked); + setPaintTool('brush'); + drawEditor(); + return; + } + + if (paintTool === 'fill') { + const displayPixels = getDisplayEditorPixels(); + const fillColor = shiftKey ? null : selectedColorCode; + const nextDisplay = EditorActions?.floodFill + ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor) + : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); + if (nextDisplay.changed) { + editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorPixels(nextDisplay.pixels, editorSize) + : nextDisplay.pixels; + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + if (fillColor === null && Array.isArray(nextDisplay.cells)) { + for (const cell of nextDisplay.cells) { + const src = displayCellToCanonical(cell.x, cell.y); + removeLightPixel(src.x, src.y); + } + } + changed = true; + } + } else if (paintTool === 'brush') { + if (editorPixels[index] !== selectedColorCode) { + editorPixels[index] = selectedColorCode; + changed = true; + } } else if (paintTool === 'erase') { - pixels[index] = null; - removeLightPixel(x, y); + if (editorPixels[index] !== null) { + editorPixels[index] = null; + changed = true; + } + const before = lightPixels.length; + removeLightPixel(point.x, point.y); + changed = changed || before !== lightPixels.length; } else if (paintTool === 'light') { - if (shiftKey || button === 2) removeLightPixel(x, y); - else addLightPixel(x, y, selectedColorCode); + const before = JSON.stringify(lightPixels); + if (shiftKey || button === 2) removeLightPixel(point.x, point.y); + else addLightPixel(point.x, point.y, selectedColorCode); + changed = JSON.stringify(lightPixels) !== before; updateSettingsSummary(); } else if (paintTool === 'depth') { - depthPixels[index] = (shiftKey || button === 2) ? 0 : 1; + const next = shiftKey ? 0 : (button === 2 ? -1 : depthPaintMode); + if (depthPixels[index] !== next) { + depthPixels[index] = next; + changed = true; + } } else if (paintTool === 'door') { if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') { - doorPixel = { x, y }; + if (doorPixel.x !== x || doorPixel.y !== y) { + doorPixel = { x, y }; + changed = true; + } updateSettingsSummary(); } } - drawEditor(); + + if (changed) { + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + markEditorChanged(); + drawEditor(); + } } function paintEventToCell(event) { @@ -863,26 +1138,670 @@ const scaleY = els.paintCanvas.height / rect.height; const sx = (clientX - rect.left) * scaleX; const sy = (clientY - rect.top) * scaleY; + const cell = els.paintCanvas.width / editorSize; return { - x: (sx - editorView.x) / editorView.zoom, - y: (sy - editorView.y) / editorView.zoom + x: (sx - editorView.x) / editorView.zoom / cell, + y: (sy - editorView.y) / editorView.zoom / cell }; } - function getActiveEditorPixels() { - return editingSide === 'left' ? editorLeftPixels : editorPixels; + function snapshotEditorState() { + return { + size: editorSize, + rightPixels: [...editorPixels], + depthPixels: [...depthPixels], + lightPixels: lightPixels.map((p) => ({ ...p })), + doorPixel: { ...doorPixel }, + editingSide, + selectedColorCode, + editorSelection: editorSelection ? { ...editorSelection } : null + }; } - function setupEditor(size, rightPixels, leftPixels, nextDepthPixels = null) { + function restoreEditorState(snapshot) { + editorSize = snapshot.size || editorSize; + editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize); + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); + lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; + doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; + editingSide = snapshot.editingSide || editingSide; + if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; + editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null; + els.assetSize.value = String(editorSize); + updateSideButtons(); + renderPalette(); + drawEditor(); + updateSettingsSummary(); + updateEditorHistoryButtons(); + updateEditorSelectionButtons(); + } + + function beginEditorGesture() { + if (!editorGestureSnapshot) { + editorGestureSnapshot = snapshotEditorState(); + editorGestureChanged = false; + } + } + + function markEditorChanged() { + editorGestureChanged = true; + } + + function finishEditorGesture() { + if (!editorGestureSnapshot) return; + if (editorGestureChanged) { + editorHistory.push(editorGestureSnapshot); + if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); + editorFuture = []; + } + editorGestureSnapshot = null; + editorGestureChanged = false; + updateEditorHistoryButtons(); + } + + function commitEditorMutation(mutator) { + const before = snapshotEditorState(); + const changed = Boolean(mutator()); + if (changed) { + editorHistory.push(before); + if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); + editorFuture = []; + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + drawEditor(); + updateSettingsSummary(); + updateEditorHistoryButtons(); + } + return changed; + } + + function clearEditorHistory() { + editorHistory = []; + editorFuture = []; + editorGestureSnapshot = null; + editorGestureChanged = false; + updateEditorHistoryButtons(); + } + + function undoEditor() { + if (!editorHistory.length) return; + editorFuture.push(snapshotEditorState()); + restoreEditorState(editorHistory.pop()); + } + + function redoEditor() { + if (!editorFuture.length) return; + editorHistory.push(snapshotEditorState()); + restoreEditorState(editorFuture.pop()); + } + + function updateEditorHistoryButtons() { + if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0; + if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0; + updateEditorSelectionButtons(); + } + + function onEditorKeyDown(event) { + if (!els.drawer?.classList.contains('open')) return; + const key = event.key.toLowerCase(); + const activeTag = document.activeElement?.tagName?.toLowerCase(); + if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return; + if ((event.ctrlKey || event.metaKey) && key === 'z') { + event.preventDefault(); + if (event.shiftKey) redoEditor(); + else undoEditor(); + } else if ((event.ctrlKey || event.metaKey) && key === 'y') { + event.preventDefault(); + redoEditor(); + } else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) { + event.preventDefault(); + const step = event.shiftKey ? 4 : 1; + const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key]; + moveSelectionBy(delta[0], delta[1]); + } else if (key === 'escape') { + editorSelection = null; + drawEditor(); + updateEditorSelectionButtons(); + } else if (!event.ctrlKey && !event.metaKey && !event.altKey) { + const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' }; + if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); } + } + } + + function fallbackFloodFill(sourcePixels, size, x, y, colorCode) { + const pixels = [...sourcePixels]; + const target = pixels[y * size + x] || null; + const replacement = colorCode || null; + if (target === replacement) return { pixels, changed: false, count: 0, cells: [] }; + const stack = [[x, y]]; + const cells = []; + while (stack.length) { + const [cx, cy] = stack.pop(); + if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue; + const index = cy * size + cx; + if ((pixels[index] || null) !== target) continue; + pixels[index] = replacement; + cells.push({ x: cx, y: cy }); + stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + } + return { pixels, changed: cells.length > 0, count: cells.length, cells }; + } + + + function handleEditorSpecialPointerDown(event) { + const cell = clientToCell(event.clientX, event.clientY); + if (!cell) return false; + if (paintTool === 'line' || paintTool === 'rect') { + beginEditorGesture(); + shapeGesture = { + tool: paintTool, + startX: cell.x, + startY: cell.y, + endX: cell.x, + endY: cell.y, + erase: event.button === 2, + filled: event.shiftKey + }; + shapePreview = { ...shapeGesture }; + drawEditor(); + return true; + } + if (paintTool === 'select') { + const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection); + if (inside) { + beginEditorGesture(); + const baseSelection = { ...editorSelection }; + selectionGesture = { + mode: 'move', + startX: cell.x, + startY: cell.y, + lastDx: 0, + lastDy: 0, + baseSelection, + basePixels: getDisplayEditorPixels(), + baseDepthPixels: getDisplayDepthPixels(), + baseLightPixels: lightPixels.map((p) => ({ ...p })) + }; + } else { + selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y }; + editorSelection = normalizeSelectionRect(cell.x, cell.y, cell.x, cell.y); + updateEditorSelectionButtons(); + drawEditor(); + } + return true; + } + return false; + } + + function handleEditorSpecialPointerMove(event) { + const cell = clientToCell(event.clientX, event.clientY); + if (!cell) return; + if (shapeGesture) { + shapeGesture.endX = cell.x; + shapeGesture.endY = cell.y; + shapeGesture.filled = event.shiftKey; + shapePreview = { ...shapeGesture }; + drawEditor(); + return; + } + if (selectionGesture?.mode === 'select') { + selectionGesture.endX = cell.x; + selectionGesture.endY = cell.y; + editorSelection = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, cell.x, cell.y); + updateEditorSelectionButtons(); + drawEditor(); + return; + } + if (selectionGesture?.mode === 'move') { + let dx = cell.x - selectionGesture.startX; + let dy = cell.y - selectionGesture.startY; + ({ dx, dy } = clampSelectionDelta(selectionGesture.baseSelection, dx, dy)); + if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return; + selectionGesture.lastDx = dx; + selectionGesture.lastDy = dy; + applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy); + markEditorChanged(); + drawEditor(); + } + } + + function handleEditorSpecialPointerUp(event) { + if (shapeGesture) { + const changed = commitShapeGesture(shapeGesture); + if (changed) markEditorChanged(); + shapeGesture = null; + shapePreview = null; + drawEditor(); + return; + } + if (selectionGesture?.mode === 'select') { + const rect = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, selectionGesture.endX, selectionGesture.endY); + editorSelection = rect && rect.w * rect.h > 0 ? rect : null; + selectionGesture = null; + drawEditor(); + updateEditorSelectionButtons(); + return; + } + if (selectionGesture?.mode === 'move') { + selectionGesture = null; + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + updateEditorSelectionButtons(); + return; + } + } + + function clientToCell(clientX, clientY) { + const local = clientToPaintLocal(clientX, clientY); + const x = Math.floor(local.x); + const y = Math.floor(local.y); + if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return null; + return { x, y }; + } + + function normalizeSelectionRect(x0, y0, x1, y1) { + const left = clamp(Math.min(x0, x1), 0, editorSize - 1); + const top = clamp(Math.min(y0, y1), 0, editorSize - 1); + const right = clamp(Math.max(x0, x1), 0, editorSize - 1); + const bottom = clamp(Math.max(y0, y1), 0, editorSize - 1); + return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 }; + } + + function pointInSelection(x, y, selection) { + return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h; + } + + function clampSelectionDelta(selection, dx, dy) { + if (!selection) return { dx: 0, dy: 0 }; + return { + dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)), + dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h)) + }; + } + + function getDisplayDepthPixels() { + const source = normalizeDepthPixels(depthPixels, editorSize); + return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorScalarPixels(source, editorSize) + : source; + } + + function setDisplayEditorPixels(pixels) { + editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorPixels(pixels, editorSize) + : normalizePixels(pixels, editorSize); + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + } + + function setDisplayDepthPixels(pixels) { + depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorScalarPixels(pixels, editorSize) + : normalizeDepthPixels(pixels, editorSize); + } + + function mirrorScalarPixels(values, size) { + const source = Array.isArray(values) ? values : []; + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + out[y * size + (size - 1 - x)] = source[y * size + x] || 0; + } + } + return out; + } + + function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) { + const nextPixels = normalizePixels(basePixels, editorSize); + const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize); + const movedPixels = [...nextPixels]; + const movedDepth = [...nextDepth]; + for (let y = selection.y; y < selection.y + selection.h; y++) { + for (let x = selection.x; x < selection.x + selection.w; x++) { + const index = y * editorSize + x; + movedPixels[index] = null; + movedDepth[index] = 0; + } + } + for (let y = selection.y; y < selection.y + selection.h; y++) { + for (let x = selection.x; x < selection.x + selection.w; x++) { + const from = y * editorSize + x; + const to = (y + dy) * editorSize + (x + dx); + movedPixels[to] = nextPixels[from] || null; + movedDepth[to] = nextDepth[from] || 0; + } + } + setDisplayEditorPixels(movedPixels); + setDisplayDepthPixels(movedDepth); + lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy); + editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h }; + updateEditorSelectionButtons(); + } + + function moveDisplayLights(sourceLights, selection, dx, dy) { + const out = []; + for (const light of sourceLights || []) { + const shown = canonicalCellToDisplay(light.x, light.y); + if (!pointInSelection(shown.x, shown.y, selection)) { + out.push({ ...light }); + continue; + } + const nx = shown.x + dx; + const ny = shown.y + dy; + if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue; + const canonical = displayCellToCanonical(nx, ny); + out.push({ ...light, x: canonical.x, y: canonical.y }); + } + return out; + } + + function moveSelectionBy(dx, dy) { + if (!editorSelection) return false; + const bounded = clampSelectionDelta(editorSelection, dx, dy); + if (!bounded.dx && !bounded.dy) return false; + return commitEditorMutation(() => { + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); + return true; + }); + } + + function nudgeEditor(dx, dy) { + if (editorSelection) moveSelectionBy(dx, dy); + else shiftEditorContent(dx, dy); + } + + function shiftEditorContent(dx, dy) { + return commitEditorMutation(() => { + const selection = { x: 0, y: 0, w: editorSize, h: editorSize }; + const bounded = clampSelectionDelta(selection, dx, dy); + if (!bounded.dx && !bounded.dy) return false; + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); + editorSelection = null; + doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) }; + return true; + }); + } + + function commitShapeGesture(gesture) { + const cells = gesture.tool === 'line' + ? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY) + : getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled); + if (!cells.length) return false; + const pixels = getDisplayEditorPixels(); + const value = gesture.erase ? null : selectedColorCode; + let changed = false; + for (const cell of cells) { + const index = cell.y * editorSize + cell.x; + if ((pixels[index] || null) !== value) { + pixels[index] = value; + changed = true; + } + if (gesture.erase) { + const canonical = displayCellToCanonical(cell.x, cell.y); + removeLightPixel(canonical.x, canonical.y); + } + } + if (!changed) return false; + setDisplayEditorPixels(pixels); + return true; + } + + function getLineCells(x0, y0, x1, y1) { + const cells = []; + let dx = Math.abs(x1 - x0); + let dy = -Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + let x = x0; + let y = y0; + while (true) { + cells.push({ x, y }); + if (x === x1 && y === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; x += sx; } + if (e2 <= dx) { err += dx; y += sy; } + } + return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize); + } + + function getRectCells(x0, y0, x1, y1, filled = false) { + const rect = normalizeSelectionRect(x0, y0, x1, y1); + const cells = []; + for (let y = rect.y; y < rect.y + rect.h; y++) { + for (let x = rect.x; x < rect.x + rect.w; x++) { + if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y }); + } + } + return cells; + } + + function drawEditorOverlays(cell) { + if (shapePreview) { + const cells = shapePreview.tool === 'line' + ? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY) + : getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled); + pctx.save(); + pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42; + pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode); + for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell)); + pctx.restore(); + } + if (editorSelection) { + pctx.save(); + pctx.strokeStyle = '#ff4fa3'; + pctx.lineWidth = Math.max(2, 2 / editorView.zoom); + pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]); + pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom); + pctx.fillStyle = 'rgba(255, 79, 163, .08)'; + pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell); + pctx.restore(); + } + } + + function updateEditorSelectionButtons() { + const disabled = !editorSelection; + if (els.clearSelection) els.clearSelection.disabled = disabled; + } + + function flipEditorHorizontal() { + commitEditorMutation(() => { + editorPixels = mirrorPixels(editorPixels, editorSize); + depthPixels = mirrorScalarPixels(depthPixels, editorSize); + lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); + doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x }; + editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; + return true; + }); + } + + function flipEditorVertical() { + commitEditorMutation(() => { + editorPixels = flipPixelsVertical(editorPixels, editorSize); + depthPixels = flipScalarPixelsVertical(depthPixels, editorSize); + lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); + doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y }; + editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; + return true; + }); + } + + function flipPixelsVertical(pixels, size) { + const source = normalizePixels(pixels, size); + const out = blankPixels(size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || null; + } + return out; + } + + function flipScalarPixelsVertical(values, size) { + const source = normalizeDepthPixels(values, size); + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || 0; + } + return out; + } + + function applyEditorOutline() { + commitEditorMutation(() => { + const source = [...editorPixels]; + const out = [...editorPixels]; + let changed = false; + for (let y = 0; y < editorSize; y++) { + for (let x = 0; x < editorSize; x++) { + const index = y * editorSize + x; + if (source[index]) continue; + const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => { + const nx = x + dx; + const ny = y + dy; + return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source[ny * editorSize + nx]; + }); + if (adjacent) { out[index] = selectedColorCode; changed = true; } + } + } + if (!changed) return false; + editorPixels = out; + return true; + }); + } + + function exportEditorPng() { + const scale = Math.max(8, Math.floor(512 / editorSize)); + const canvas = document.createElement('canvas'); + canvas.width = editorSize * scale; + canvas.height = editorSize * scale; + const c = canvas.getContext('2d'); + c.imageSmoothingEnabled = false; + const pixels = getDisplayEditorPixels(); + for (let y = 0; y < editorSize; y++) { + for (let x = 0; x < editorSize; x++) { + const code = pixels[y * editorSize + x]; + if (!code) continue; + c.fillStyle = colorToHex(code); + c.fillRect(x * scale, y * scale, scale, scale); + } + } + const link = document.createElement('a'); + const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art'; + link.download = `${rawName}-${editorSize}x${editorSize}.png`; + link.href = canvas.toDataURL('image/png'); + link.click(); + toast('PNG exported.'); + } + + function importEditorPng(event) { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + const img = new Image(); + img.onload = () => { + const targetSize = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height); + commitEditorMutation(() => { + const previousSize = editorSize; + const canvas = document.createElement('canvas'); + canvas.width = targetSize; + canvas.height = targetSize; + const c = canvas.getContext('2d', { willReadFrequently: true }); + c.imageSmoothingEnabled = false; + c.clearRect(0, 0, targetSize, targetSize); + const fit = fitImageInsideSquare(img.naturalWidth || img.width, img.naturalHeight || img.height, targetSize); + c.drawImage(img, fit.dx, fit.dy, fit.dw, fit.dh); + const data = c.getImageData(0, 0, targetSize, targetSize).data; + const next = blankPixels(targetSize); + for (let i = 0; i < targetSize * targetSize; i++) { + const alpha = data[i * 4 + 3]; + if (alpha < 32) continue; + const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]); + next[i] = nearestPaletteCode(hex); + } + editorSize = targetSize; + els.assetSize.value = String(targetSize); + setDisplayEditorPixels(next); + depthPixels = blankPixels(targetSize).map(() => 0); + lightPixels = []; + doorPixel = { x: Math.min(doorPixel.x, targetSize - 1), y: Math.min(doorPixel.y, targetSize - 1) }; + editorSelection = null; + if (previousSize !== targetSize) resetEditorView(); + return true; + }); + URL.revokeObjectURL(img.src); + const note = (img.naturalWidth === targetSize && img.naturalHeight === targetSize) ? '' : ` (fit from ${img.naturalWidth}×${img.naturalHeight})`; + toast(`PNG imported as ${targetSize}×${targetSize}${note}.`); + }; + img.onerror = () => toast('Could not read that PNG.'); + img.src = URL.createObjectURL(file); + } + + function chooseImportCanvasSize(width, height) { + const allowed = [8, 16, 32, 64]; + const w = Math.max(1, Math.round(Number(width) || 1)); + const h = Math.max(1, Math.round(Number(height) || 1)); + if (w === h && allowed.includes(w)) return w; + const target = Math.max(w, h); + return allowed.find((size) => target <= size) || allowed[allowed.length - 1]; + } + + function fitImageInsideSquare(width, height, size) { + const w = Math.max(1, Number(width) || 1); + const h = Math.max(1, Number(height) || 1); + const scale = Math.min(size / w, size / h); + const dw = Math.max(1, Math.round(w * scale)); + const dh = Math.max(1, Math.round(h * scale)); + return { + dx: Math.floor((size - dw) / 2), + dy: Math.floor((size - dh) / 2), + dw, + dh + }; + } + + function rgbToHex(r, g, b) { + const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'); + return `#${part(r)}${part(g)}${part(b)}`; + } + + function selectPaletteCode(code) { + if (!PALETTE_BY_CODE[code]) return; + selectedColorCode = code; + els.paintColor.value = PALETTE_BY_CODE[code]; + renderPalette(); + } + + function getActiveEditorPixels() { + return editorPixels; + } + + function getDisplayEditorPixels() { + return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorPixels(editorPixels, editorSize) + : normalizePixels(editorPixels, editorSize); + } + + function displayCellToCanonical(x, y) { + if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; + return { x, y }; + } + + function canonicalCellToDisplay(x, y) { + if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; + return { x, y }; + } + + function setupEditor(size, rightPixels, leftPixels = null, nextDepthPixels = null) { editorSize = size; - editorPixels = normalizePixels(rightPixels, size); - editorLeftPixels = normalizePixels(leftPixels, size); - depthPixels = Array.isArray(nextDepthPixels) ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size); + const normalizedRight = normalizePixels(rightPixels, size); + const normalizedLeft = leftPixels ? normalizePixels(leftPixels, size) : null; + const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorPixels(normalizedLeft, size); + editorPixels = normalizePixels(canonical, size); + editorLeftPixels = mirrorPixels(editorPixels, size); + editorSelection = null; + depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size); els.assetSize.value = String(size); lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; resetEditorView(); drawEditor(); + updateEditorHistoryButtons(); } function drawEditor() { @@ -898,7 +1817,7 @@ pctx.translate(editorView.x, editorView.y); pctx.scale(editorView.zoom, editorView.zoom); - const pixels = getActiveEditorPixels(); + const pixels = getDisplayEditorPixels(); for (let y = 0; y < editorSize; y++) { for (let x = 0; x < editorSize; x++) { const color = pixels[y * editorSize + x]; @@ -915,11 +1834,13 @@ } for (let y = 0; y < editorSize; y++) { for (let x = 0; x < editorSize; x++) { - const depth = depthPixels[y * editorSize + x] || 0; + const source = displayCellToCanonical(x, y); + const depth = depthPixels[source.y * editorSize + source.x] || 0; if (!depth) continue; - pctx.fillStyle = 'rgba(34, 116, 255, .62)'; + const high = depth > 0; + pctx.fillStyle = high ? 'rgba(34, 116, 255, .62)' : 'rgba(88, 44, 120, .62)'; pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); - pctx.strokeStyle = 'rgba(6, 28, 75, .55)'; + pctx.strokeStyle = high ? 'rgba(6, 28, 75, .55)' : 'rgba(35, 13, 54, .55)'; pctx.lineWidth = Math.max(1, 2 / editorView.zoom); pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); } @@ -936,8 +1857,9 @@ { for (const light of lightPixels) { - const lx = (light.x + .5) * cell; - const ly = (light.y + .5) * cell; + const shown = canonicalCellToDisplay(light.x, light.y); + const lx = (shown.x + .5) * cell; + const ly = (shown.y + .5) * cell; pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28); pctx.beginPath(); pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2); @@ -954,6 +1876,7 @@ pctx.strokeRect(doorPixel.x * cell + 2 / editorView.zoom, doorPixel.y * cell + 2 / editorView.zoom, cell - 4 / editorView.zoom, cell - 4 / editorView.zoom); } } + drawEditorOverlays(cell); pctx.restore(); } @@ -988,7 +1911,6 @@ const shift = getBottomShift(editorPixels, size); return { rightPixels: shiftPixelsVertical(editorPixels, size, shift), - leftPixels: shiftPixelsVertical(editorLeftPixels, size, shift), depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift), lightPixels: lightPixels .map((p) => ({ ...p, y: p.y + shift })) @@ -1060,16 +1982,11 @@ pixels: encodePixels(aligned.rightPixels), faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels), - left: encodePixels(hasAnyPixel(aligned.leftPixels) ? aligned.leftPixels : mirrorPixels(aligned.rightPixels, size)) + left: 'mirror' } : null, - meta: { - hasLight: aligned.lightPixels.length > 0, - lightPixels: aligned.lightPixels.map((p) => ({ x: Math.floor(p.x), y: Math.floor(p.y), c: p.c || selectedColorCode })), - lightColor: selectedColorCode, - depthPixels: encodeDepthPixels(aligned.depthPixels), - door: category === 'static' && subtype === 'building' ? aligned.door : null - } + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size) }; + asset.contentHash = computeAssetContentHash(asset); if (existing) { const index = state.assets.findIndex((a) => a.id === existing.id); if (index >= 0) state.assets[index] = asset; @@ -1079,6 +1996,7 @@ toast(`${asset.name} saved.`); } selectedAssetId = asset.id; + recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); editParentId = null; editOriginalId = null; editingAssetId = null; @@ -1112,7 +2030,8 @@ lightPixels = []; depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; - setupEditor(8, blankPixels(8), blankPixels(8)); + setupEditor(8, blankPixels(8), null); + clearEditorHistory(); refreshCategoryUI(); els.lineageNote.textContent = ''; } @@ -1313,9 +2232,7 @@ swatch.title = `${entry.code} ${entry.color}`; swatch.style.background = entry.color; swatch.addEventListener('click', () => { - selectedColorCode = entry.code; - els.paintColor.value = entry.color; - renderPalette(); + selectPaletteCode(entry.code); drawEditor(); }); els.paletteGrid.append(swatch); @@ -1347,12 +2264,12 @@ staticKind = asset.category === 'static' ? asset.subtype : staticKind; dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; editingSide = 'right'; - const right = asset.faces?.right || asset.pixels || blankPixels(asset.size); - const left = asset.faces?.left || mirrorPixels(right, asset.size); + const right = getAssetPixels(asset, 'right'); lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size); doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 }; - setupEditor(asset.size, right, left, depthPixels); + setupEditor(asset.size, right, null, depthPixels); + clearEditorHistory(); refreshCategoryUI(); els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`; } @@ -1393,7 +2310,9 @@ y: spawn.y + .5, targetX: spawn.x + .5, targetY: spawn.y + .5, - vx: 1, + vx: 0, + facing: 1, + idleUntil: performance.now() + 700 + Math.random() * 1600, hiddenUntil: 0, seed: Math.random() * 9999, nextDecisionAt: 0, @@ -1435,16 +2354,26 @@ spawnFishBubbleCluster(item.x, item.y, time, item.seed); item.nextBubbleAt = time + 1100 + Math.random() * 1800; } + if (time < (item.idleUntil || 0)) { + item.vx = 0; + continue; + } const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); + if (time < (item.idleUntil || 0)) { + item.vx = 0; + continue; + } - const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; const dx = item.targetX - item.x; const dy = item.targetY - item.y; const len = Math.hypot(dx, dy) || 1; - item.vx = dx === 0 ? item.vx : Math.sign(dx); - item.x += (dx / len) * speed; - item.y += (dy / len) * speed; + const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; + const step = Math.min(speed, len); + if (Math.abs(dx) > 0.02) item.facing = Math.sign(dx); + item.vx = Math.abs(dx) > 0.02 ? Math.sign(dx) : 0; + item.x += (dx / len) * step; + item.y += (dy / len) * step; if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) { item.hiddenUntil = time + 1700 + Math.random() * 2200; @@ -1453,6 +2382,16 @@ } function chooseTarget(item, asset, time) { + const idleChance = ({ human: .34, animal: .42, fish: .24, bird: .16 }[asset.subtype] || .32); + if (Math.random() < idleChance) { + item.idleUntil = time + 900 + Math.random() * 2600; + item.targetX = item.x; + item.targetY = item.y; + item.vx = 0; + item.nextDecisionAt = item.idleUntil + 400 + Math.random() * 1200; + return; + } + let target = null; if (asset.subtype === 'human') target = findNearestPlaced(item.x, item.y, 'building', 14); else if (asset.subtype === 'animal' || asset.subtype === 'bird') target = findNearestPlaced(item.x, item.y, 'nature', 16); @@ -1465,31 +2404,74 @@ item.targetX = spawn.x + .5; item.targetY = spawn.y + .5; } - item.nextDecisionAt = time + 1600 + Math.random() * 2800; + item.idleUntil = 0; + item.nextDecisionAt = time + 1800 + Math.random() * 3200; } function findNearestPlaced(x, y, subtype, maxDistance) { let best = null; let bestDistance = maxDistance; - for (const placed of state.placed) { - const asset = findAsset(placed.assetId); - if (!asset || asset.subtype !== subtype) continue; - const distance = Math.hypot(placed.x + .5 - x, placed.y + .5 - y); - if (distance < bestDistance) { - bestDistance = distance; - best = placed; + const minX = clampInt(Math.floor(x - maxDistance), 0, WORLD_W - 1, 0); + const maxX = clampInt(Math.ceil(x + maxDistance), 0, WORLD_W - 1, WORLD_W - 1); + const minY = clampInt(Math.floor(y - maxDistance), 0, WORLD_H - 1, 0); + const maxY = clampInt(Math.ceil(y + maxDistance), 0, WORLD_H - 1, WORLD_H - 1); + for (let ty = minY; ty <= maxY; ty++) { + for (let tx = minX; tx <= maxX; tx++) { + const bucket = worldIndex.placedByTile.get(tileKey(tx, ty)); + if (!bucket) continue; + for (const placed of bucket) { + const asset = findAsset(placed.assetId); + if (!asset || asset.subtype !== subtype) continue; + const distance = Math.hypot(placed.x + .5 - x, placed.y + .5 - y); + if (distance < bestDistance) { + bestDistance = distance; + best = placed; + } + } } } return best; } + + function scheduleFrame() { + if (animationFrameId || document.hidden) return; + animationFrameId = requestAnimationFrame(tick); + } + + function onVisibilityChange() { + if (document.hidden) { + if (animationFrameId) cancelAnimationFrame(animationFrameId); + animationFrameId = 0; + lastRuntimeUpdate = performance.now(); + dynamicLogicRemainder = 0; + return; + } + lastRuntimeUpdate = performance.now(); + dynamicLogicRemainder = 0; + render(lastRuntimeUpdate); + scheduleFrame(); + } + function tick(time) { - const dt = Math.min(.05, (time - lastRuntimeUpdate) / 1000); + animationFrameId = 0; + if (document.hidden) { + lastRuntimeUpdate = time; + return; + } + const elapsed = Math.min(100, Math.max(0, time - lastRuntimeUpdate)); lastRuntimeUpdate = time; - updateDynamicRuntime(dt, time); + dynamicLogicRemainder += elapsed; + let steps = 0; + while (dynamicLogicRemainder >= DYNAMIC_LOGIC_STEP_MS && steps < MAX_DYNAMIC_STEPS_PER_FRAME) { + updateDynamicRuntime(DYNAMIC_LOGIC_STEP_MS / 1000, time); + dynamicLogicRemainder -= DYNAMIC_LOGIC_STEP_MS; + steps++; + } + if (steps >= MAX_DYNAMIC_STEPS_PER_FRAME) dynamicLogicRemainder = 0; updateClock(); render(time); - requestAnimationFrame(tick); + scheduleFrame(); } function updateClock() { @@ -1527,7 +2509,7 @@ { at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, { at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, { at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] }, - { at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] } + { at: 10, key: 'night', label: 'Night', darkness: 0.68, tint: [18, 25, 64, 0.30] } ]; let a = stops[0], b = stops[1]; for (let i = 0; i < stops.length - 1; i++) { @@ -1560,7 +2542,7 @@ ctx.translate(view.x, view.y); ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; - ctx.drawImage(terrainCache.canvas, 0, 0); + drawTerrainCache(terrainCache); drawHoverTile(); const lightSources = []; drawObjects(time, lightSources, phase); @@ -1621,7 +2603,7 @@ } function drawObjects(time, lightSources, phase) { - const items = getDrawableItems(time); + const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); for (const item of items) { if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { @@ -1651,17 +2633,20 @@ if (asset.category === 'static' && asset.subtype === 'water') bob += Math.sin(time / 900 + item.x * .7) * 1.6; if (asset.category === 'dynamic') { const runtime = item.source; - side = runtime.vx < 0 ? 'left' : 'right'; + side = (runtime.facing || runtime.vx || 1) < 0 ? 'left' : 'right'; if (asset.subtype === 'human' || asset.subtype === 'animal') { - const jumpPhase = time / 78 + runtime.seed * 0.9; - const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); - const liftNow = getLiftAtCoord(item.x, item.y); - const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); - const terrainBoost = Math.abs(liftTarget - liftNow) / 12; - bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); - const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); - const rand = pseudoNoise(cycle + runtime.seed * 13.17); - angle = (rand - 0.5) * 0.24 * hop; + const idle = time < (runtime.idleUntil || 0); + if (!idle) { + const jumpPhase = time / 78 + runtime.seed * 0.9; + const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); + const liftNow = getLiftAtCoord(item.x, item.y); + const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); + const terrainBoost = Math.abs(liftTarget - liftNow) / 12; + bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); + const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); + const rand = pseudoNoise(cycle + runtime.seed * 13.17); + angle = (rand - 0.5) * 0.24 * hop; + } } if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5; if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; } @@ -1673,7 +2658,7 @@ } function drawSpriteItem(item, time, lightSources, underwater, phase) { - const { asset, pos, sprite, drawX, drawY, alpha, angle } = getSpriteDrawInfo(item, time, true); + const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); @@ -1696,7 +2681,8 @@ if (asset.meta?.hasLight && asset.meta.lightPixels?.length) { const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; for (const light of asset.meta.lightPixels) { - const wx = drawX + (light.x + 0.5) * scale; + const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x; + const wx = drawX + (lx + 0.5) * scale; const wy = drawY + (light.y + 0.5) * scale; lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(12, 20, asset.size / 64) }); } @@ -2016,7 +3002,7 @@ for (let x = 0; x < asset.size; x++) { const color = pixels[y * asset.size + x]; if (!color) continue; - c.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y)); + c.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y, side)); c.fillRect(x * scale, y * scale, scale, scale); } } @@ -2025,11 +3011,9 @@ } function getAssetPixels(asset, side = 'right') { - if (asset.category === 'dynamic') { - if (side === 'left') return normalizePixels(asset.faces?.left || mirrorPixels(normalizePixels(asset.faces?.right || asset.pixels || [], asset.size), asset.size), asset.size); - return normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size); - } - return normalizePixels(asset.pixels || blankPixels(asset.size), asset.size); + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size); + if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size); + return right; } function makeWorld() { @@ -2074,23 +3058,66 @@ } function buildTerrainCache(worldData) { - const max = tileToWorld(WORLD_W + 1, WORLD_H + 1); - const width = ORIGIN_X + WORLD_W * TILE_W / 2 + 160; - const height = max.y + TILE_H * 4; - const canvas = document.createElement('canvas'); - canvas.width = Math.ceil(width); - canvas.height = Math.ceil(height); - const c = canvas.getContext('2d'); - c.imageSmoothingEnabled = false; - c.fillStyle = '#74c8e4'; - c.fillRect(0, 0, canvas.width, canvas.height); - - for (const tile of worldData.tiles) { - drawTerrainTile(c, tile, worldData); + const chunkSize = TERRAIN_CHUNK_SIZE; + const chunks = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let cy = 0; cy < WORLD_H; cy += chunkSize) { + for (let cx = 0; cx < WORLD_W; cx += chunkSize) { + const tiles = []; + for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) { + for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) { + const tile = worldData.get(x, y); + if (tile) tiles.push(tile); + } + } + if (!tiles.length) continue; + const bounds = getTerrainChunkBounds(tiles); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(bounds.w); + canvas.height = Math.ceil(bounds.h); + const c = canvas.getContext('2d', { alpha: true }); + c.imageSmoothingEnabled = false; + c.save(); + c.translate(-bounds.x, -bounds.y); + for (const tile of tiles) drawTerrainTile(c, tile, worldData); + c.restore(); + chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy }); + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + canvas.width); + maxY = Math.max(maxY, bounds.y + canvas.height); + } } - return { canvas, bounds: { x: 0, y: 0, w: canvas.width, h: canvas.height } }; + if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize }; + return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize }; } + function getTerrainChunkBounds(tiles) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const tile of tiles) { + const p = tileToWorld(tile.x, tile.y); + const lift = getTileLift(tile); + minX = Math.min(minX, p.x - TILE_W / 2 - 3); + maxX = Math.max(maxX, p.x + TILE_W / 2 + 3); + minY = Math.min(minY, p.y - lift - 16); + maxY = Math.max(maxY, p.y + TILE_H + 18); + } + minX = Math.floor(minX); + minY = Math.floor(minY); + maxX = Math.ceil(maxX); + maxY = Math.ceil(maxY); + return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) }; + } + + function drawTerrainCache(cache) { + const rect = getViewportWorldRect(64); + for (const chunk of cache.chunks) { + if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue; + ctx.drawImage(chunk.canvas, chunk.x, chunk.y); + } + } + + function drawTerrainTile(c, tile, worldData) { const { x, y } = tileToWorld(tile.x, tile.y); const palette = { @@ -2175,7 +3202,8 @@ const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); - if (parsed && parsed.schema === SAVE_SCHEMA && Array.isArray(parsed.assets)) return normalizeState(parsed); + const expanded = Phase2Sync?.isCompactState?.(parsed) ? Phase2Sync.expandState(parsed) : parsed; + if (expanded && Array.isArray(expanded.assets)) return normalizeState(expanded); } } catch (error) { console.warn('Could not load local state.', error); @@ -2185,7 +3213,11 @@ function saveState() { state.schema = SAVE_SCHEMA; - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : []; + const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + cachePhase2State(); + updateSyncStats(); } function normalizeState(input) { @@ -2193,41 +3225,89 @@ schema: SAVE_SCHEMA, authorName: input.authorName || 'Local Artist', assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [], - placed: dedupeByAsset(Array.isArray(input.placed) ? input.placed : []), - dynamicSummons: dedupeByAsset(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []), + placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []), + dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []), objectVotes: input.objectVotes || {}, assetVotes: input.assetVotes || {}, hiddenAssets: input.hiddenAssets || {}, - hiddenObjects: input.hiddenObjects || {} + hiddenObjects: input.hiddenObjects || {}, + eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], + sync: input.sync || { lastEventId: null } }; } function normalizeAsset(asset) { const size = Number(asset.size) || 16; const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; - const base = normalizePixels(asset.pixels || asset.faces?.right || blankPixels(size), size); - const right = normalizePixels(asset.faces?.right || base, size); - const left = normalizePixels(asset.faces?.left || mirrorPixels(right, size), size); - return { + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size); + const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other'); + const normalized = { id: asset.id || uid(), name: asset.name || 'Untitled', category, - subtype: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), + subtype, size, - pixels: encodePixels(base), - faces: category === 'dynamic' ? { right: encodePixels(right), left: encodePixels(left) } : null, + pixels: encodePixels(right), + faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null, parentAssetId: asset.parentAssetId || null, originalAssetId: asset.originalAssetId || null, createdAt: asset.createdAt || Date.now(), + updatedAt: asset.updatedAt || asset.createdAt || Date.now(), author: asset.author || 'Local Artist', - meta: { - hasLight: Boolean(asset.meta?.hasLight || (asset.meta?.lightPixels || []).length), - lightPixels: Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels.map((p) => ({ x: Number(p.x), y: Number(p.y), c: p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a') })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : [], - lightColor: nearestPaletteCode(asset.meta?.lightColor || '#ffd86a'), - depthPixels: encodeDepthPixels(normalizeDepthPixels(asset.meta?.depthPixels || [], size)), - door: asset.meta?.door || null - } + meta: normalizeAssetMeta(asset.meta || {}, category, subtype, size) }; + normalized.contentHash = computeAssetContentHash(normalized); + return normalized; + } + + function normalizeAssetMeta(meta, category, subtype, size) { + const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size); + const lightPixels = Array.isArray(meta.lightPixels) + ? meta.lightPixels + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + : []; + return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size); + } + + function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize) { + const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); + const hasDepth = normalizedDepth.some(Boolean); + const lightPixelsClean = Array.isArray(sourceLightPixels) + ? sourceLightPixels + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + : []; + const hasLight = lightPixelsClean.length > 0; + return { + hasLight, + lightPixels: hasLight ? lightPixelsClean : [], + lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, + depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null, + door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null + }; + } + + function normalizePlacements(items) { + return dedupeByAsset(items).map((item) => ({ + id: item.id || uid(), + assetId: item.assetId, + x: clampInt(item.x, 0, WORLD_W - 1, 0), + y: clampInt(item.y, 0, WORLD_H - 1, 0), + placedAt: item.placedAt || item.createdAt || Date.now(), + version: Number(item.version) || 1 + })).filter((item) => item.assetId); + } + + function normalizeDynamicSummons(items) { + return dedupeByAsset(items).map((item) => ({ + id: item.id || uid(), + assetId: item.assetId, + homeX: clampInt(item.homeX ?? item.x, 0, WORLD_W - 1, 0), + homeY: clampInt(item.homeY ?? item.y, 0, WORLD_H - 1, 0), + createdAt: item.createdAt || item.placedAt || Date.now(), + version: Number(item.version) || 1 + })).filter((item) => item.assetId); } function dedupeByAsset(items) { @@ -2298,27 +3378,23 @@ const size = 16; const id = uid(); const right = alignPixelsToBottom(normalizePixels(pixels, size), size); - const left = alignPixelsToBottom(normalizePixels(leftPixels || mirrorPixels(right, size), size), size); - return { + const asset = { id, name, category, subtype, size, pixels: encodePixels(right), - faces: category === 'dynamic' ? { right: encodePixels(right), left: encodePixels(left) } : null, + faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null, parentAssetId: null, originalAssetId: null, createdAt: Date.now(), + updatedAt: Date.now(), author: 'Island Team', - meta: { - hasLight: Boolean(meta.hasLight), - lightPixels: (meta.lightPixels || []).map((p) => ({ ...p, c: p.c || nearestPaletteCode(meta.lightColor || '#ffd86a') })), - lightColor: nearestPaletteCode(meta.lightColor || '#ffd86a'), - depthPixels: encodeDepthPixels(normalizeDepthPixels(meta.depthPixels || [], size)), - door: meta.door || null - } + meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size) }; + asset.contentHash = computeAssetContentHash(asset); + return asset; } @@ -2344,13 +3420,55 @@ function exportData() { els.dataBox.value = JSON.stringify(state, null, 2); - toast('JSON exported.'); + toast('Full JSON exported.'); + } + + function exportCompactData() { + const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; + els.dataBox.value = JSON.stringify(payload); + toast('Compact JSON exported.'); + } + + function exportSnapshotData() { + if (!Phase2Sync?.makeSnapshot) { + exportData(); + return; + } + els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state)); + toast('Snapshot JSON exported.'); + } + + function exportAssetBundleData() { + if (!Phase2Sync?.makeAssetBundle) { + exportData(); + return; + } + els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id))); + toast('Asset bundle JSON exported.'); } function importData() { try { const imported = JSON.parse(els.dataBox.value); - state = normalizeState(imported); + if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) { + toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.'); + return; + } + if (Phase2Sync?.isAssetBundle?.(imported)) { + const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset); + const byId = new Map(state.assets.map((asset) => [asset.id, asset])); + importedAssets.forEach((asset) => byId.set(asset.id, asset)); + state.assets = [...byId.values()]; + saveState(); + spriteCache.clear(); + renderLibrary(); + updateSelectedLabel(); + toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`); + return; + } + const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported; + state = normalizeState(expanded); + rebuildWorldIndex(); hydrateAuthorUI(); selectedAssetId = state.assets[0]?.id ?? null; saveState(); @@ -2369,6 +3487,7 @@ if (!confirm('Reset all local assets and island objects?')) return; localStorage.removeItem(STORAGE_KEY); state = seedState(); + rebuildWorldIndex(); hydrateAuthorUI(); selectedAssetId = state.assets[0]?.id ?? null; saveState(); @@ -2379,6 +3498,65 @@ toast('Local world reset.'); } + function recordSyncEvent(event) { + if (!event) return; + state.eventLog ||= []; + state.eventLog.push(event); + state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)); + state.sync ||= { lastEventId: null }; + state.sync.lastEventId = event.id; + } + + function cachePhase2State() { + if (!Phase2Sync?.cacheAssets) return; + Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); + if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) { + Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error)); + } + } + + function updateSyncStats() { + if (!els.syncStats) return; + const report = Phase2Sync?.compactSizeReport?.(state); + if (!report) { + els.syncStats.textContent = 'Phase 2 codec unavailable.'; + return; + } + const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet'; + const statsRect = getViewportWorldRect(64); + const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0; + els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`; + } + + function computeAssetContentHash(asset) { + const payload = [ + asset.category || '', + asset.subtype || '', + asset.size || '', + asset.pixels || '', + asset.faces?.right || '', + asset.faces?.left || '', + asset.meta?.depthPixels || '', + JSON.stringify(asset.meta?.lightPixels || []), + asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' + ].join('|'); + return `fnv1a:${fnv1a(payload)}`; + } + + function fnv1a(value) { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); + } + + function clampInt(value, min, max, fallback = min) { + const n = Math.round(Number(value)); + return Number.isFinite(n) ? clamp(n, min, max) : fallback; + } + function findAsset(id) { return state.assets.find((asset) => asset.id === id) || null; } @@ -2451,16 +3629,22 @@ function normalizeDepthPixels(input, size) { const out = Array(size * size).fill(0); if (typeof input === 'string') { - for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] === '1' ? 1 : 0; + for (let i = 0; i < Math.min(out.length, input.length); i++) { + const ch = input[i]; + out[i] = ch === '1' ? 1 : (ch === '-' || ch === '_' || ch === 'l' || ch === 'L') ? -1 : 0; + } return out; } if (!Array.isArray(input)) return out; - for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] ? 1 : 0; + for (let i = 0; i < Math.min(out.length, input.length); i++) { + const value = Number(input[i]) || 0; + out[i] = value > 0 ? 1 : value < 0 ? -1 : 0; + } return out; } function encodeDepthPixels(input) { - return Array.from(input || []).map((v) => v ? '1' : '.').join(''); + return Array.from(input || []).map((v) => Number(v) > 0 ? '1' : Number(v) < 0 ? '-' : '.').join(''); } function resizeDepthPixels(source, oldSize, newSize) { @@ -2478,20 +3662,21 @@ return out; } - function getAssetDepth(asset, x, y) { + function getAssetDepth(asset, x, y, side = 'right') { const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); - return depth[y * asset.size + x] || 0; + const sx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - x : x; + return depth[y * asset.size + sx] || 0; } function applyDepthToColor(hex, depth) { if (!depth) return hex; const rgb = parseHex(hex); if (!rgb) return hex; - const amt = 34; - const r = Math.min(255, rgb.r + amt); - const g = Math.min(255, rgb.g + amt); - const b = Math.min(255, rgb.b + amt); - return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`; + const amt = depth > 0 ? 34 : -34; + const r = clamp(rgb.r + amt, 0, 255); + const g = clamp(rgb.g + amt, 0, 255); + const b = clamp(rgb.b + amt, 0, 255); + return `#${Math.round(r).toString(16).padStart(2,'0')}${Math.round(g).toString(16).padStart(2,'0')}${Math.round(b).toString(16).padStart(2,'0')}`; } function resizePixels(source, oldSize, newSize) { diff --git a/app.js.bak b/app.js.bak new file mode 100644 index 0000000..f1b742c --- /dev/null +++ b/app.js.bak @@ -0,0 +1,3774 @@ +(() => { + 'use strict'; + + console.info('Pixel Island Summoner loaded'); + + const STORAGE_KEY = 'pixel-island-summoner:reset'; + const SAVE_SCHEMA = 5; + const WORLD_W = 144; + const WORLD_H = 112; + const TILE_W = 32; + const TILE_H = 16; + const ORIGIN_X = WORLD_H * TILE_W / 2 + 80; + const ORIGIN_Y = 42; + const DAY_MS = 10 * 60 * 1000; + const STATIC_SCALE = 2; + const DYNAMIC_SCALE = 1; + const MAX_ZOOM = 3.2; + const MIN_ZOOM = 0.45; + const TERRAIN_CHUNK_SIZE = 16; + const VIEW_CULL_MARGIN = 192; + const DYNAMIC_LOGIC_STEP_MS = 1000 / 15; + const MAX_DYNAMIC_STEPS_PER_FRAME = 3; + + const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const PALETTE = buildPalette(); + const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color])); + const MODULES = window.PixelIslandModules || {}; + const EditorActions = MODULES.EditorActions || null; + const Phase2Sync = MODULES.Phase2Sync || null; + const EDITOR_HISTORY_LIMIT = 80; + + const $ = (id) => document.getElementById(id); + const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5); + const mod = (n, m) => ((n % m) + m) % m; + const lerp = (a, b, t) => a + (b - a) * t; + + + function toggleHidden(el, hidden) { + if (el) el.hidden = hidden; + } + + function currentRole() { + return els.assetCategory?.value || 'nature'; + } + + function roleToCategory(role) { + return role === 'human' || role === 'animal' ? 'dynamic' : 'static'; + } + + function roleToSubtype(role) { + return role || 'other'; + } + + function subtypeToRole(asset) { + const subtype = asset?.subtype; + if (['human', 'animal', 'nature', 'building', 'other'].includes(subtype)) return subtype; + if (subtype === 'water') return 'other'; + if (asset?.category === 'dynamic') return 'animal'; + return 'other'; + } + + const els = { + canvas: $('worldCanvas'), + openEditor: $('openEditor'), + closeEditor: $('closeEditor'), + drawer: $('studioDrawer'), + tabs: [...document.querySelectorAll('.tab')], + panels: [...document.querySelectorAll('.tabPanel')], + phaseLabel: $('phaseLabel'), + phaseBar: $('phaseBar'), + authorName: $('authorName'), + selectedAssetName: $('selectedAssetName'), + tileInfo: $('tileInfo'), + toast: $('toast'), + selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleHide: $('bubbleHide'), + + modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), + drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), + zoomOut: $('zoomOut'), zoomIn: $('zoomIn'), resetView: $('resetView'), + drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'), + + assetName: $('assetName'), + assetSize: $('assetSize'), + assetCategory: $('assetCategory'), + staticKindWrap: $('staticKindWrap'), + dynamicKindWrap: $('dynamicKindWrap'), + roleHint: $('roleHint'), + sideSwitcher: $('sideSwitcher'), + editRight: $('editRight'), editLeft: $('editLeft'), + paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), + toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), + paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), + lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), + settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), + lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), + exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'), + exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats') + }; + + const ctx = els.canvas.getContext('2d', { alpha: false }); + const pctx = els.paintCanvas.getContext('2d', { alpha: true }); + ctx.imageSmoothingEnabled = false; + pctx.imageSmoothingEnabled = false; + + let dpr = window.devicePixelRatio || 1; + let cw = 1; + let ch = 1; + + let world = makeWorld(); + let terrainCache = buildTerrainCache(world); + let state = loadState(); + let selectedAssetId = state.assets[0]?.id ?? null; + let mode = 'inspect'; + let view = { x: 0, y: 0, zoom: 1 }; + let pointer = { + down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0, + dragging: false, downTime: 0 + }; + let hoverTile = null; + let dynamicRuntime = []; + let spriteCache = new Map(); + let lastRuntimeUpdate = performance.now(); + let lastClockSecond = -1; + let toastTimer = null; + + let editorSize = 8; + let editorPixels = blankPixels(8); + let editorLeftPixels = blankPixels(8); + let editingSide = 'right'; + let paintTool = 'brush'; + let selectedColorCode = 'a'; + let advancedDraw = false; + let depthPixels = blankPixels(8).map(() => 0); + let isPainting = false; + let staticKind = 'nature'; + let dynamicKind = 'human'; + let lightPixels = []; + let doorPixel = { x: 8, y: 15 }; + let editParentId = null; + let editOriginalId = null; + let editingAssetId = null; + let lastPaintedKey = ''; + let spawnEffects = []; + let bubbleParticles = []; + let confettiParticles = []; + let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; + let shadowCanvasCache = new WeakMap(); + let animationFrameId = 0; + let dynamicLogicRemainder = 0; + let worldIndex = makeEmptyWorldIndex(); + let selectedObject = null; + let libraryFilter = 'all'; + let renderPhase = null; + let editorView = { zoom: 1, x: 0, y: 0 }; + let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 }; + let editorHistory = []; + let editorFuture = []; + let editorGestureSnapshot = null; + let editorGestureChanged = false; + let suppressNextPaintClick = false; + let suppressMousePaintUntil = 0; + let editorSelection = null; + let selectionGesture = null; + let shapeGesture = null; + let shapePreview = null; + + function bootstrap() { + resizeCanvas(); + resetView(false); + hydrateRuntime(); + wireUI(); + renderPalette(); + hydrateAuthorUI(); + refreshCategoryUI(); + setupEditor(8, blankPixels(8), null); + clearEditorHistory(); + renderLibrary(); + updateSelectedLabel(); + rebuildWorldIndex(); + updateSyncStats(); + scheduleFrame(); + } + + function wireUI() { + window.addEventListener('resize', () => { + resizeCanvas(); + render(); + }); + + els.openEditor.addEventListener('click', () => setDrawerOpen(true)); + els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); + + els.tabs.forEach((button) => { + button.addEventListener('click', () => setTab(button.dataset.tab)); + }); + + els.authorName?.addEventListener('input', () => { + state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; + saveState(); + renderLibrary(); + }); + + [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect'))); + [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place'))); + [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase'))); + [els.zoomOut, els.drawerZoomOut].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom / 1.2))); + [els.zoomIn, els.drawerZoomIn].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom * 1.2))); + [els.resetView, els.drawerResetView].filter(Boolean).forEach((el) => el.addEventListener('click', () => resetView(true))); + + els.canvas.addEventListener('pointerdown', onWorldPointerDown); + els.canvas.addEventListener('pointermove', onWorldPointerMove); + els.canvas.addEventListener('pointerup', onWorldPointerUp); + els.canvas.addEventListener('pointercancel', onWorldPointerUp); + els.canvas.addEventListener('wheel', onWorldWheel, { passive: false }); + els.canvas.addEventListener('contextmenu', (event) => event.preventDefault()); + document.addEventListener('contextmenu', onDocumentContextMenu); + document.addEventListener('visibilitychange', onVisibilityChange); + + els.assetSize.addEventListener('change', () => { + const oldSize = editorSize; + const nextSize = Number(els.assetSize.value); + commitEditorMutation(() => { + const resizedRight = resizePixels(editorPixels, oldSize, nextSize); + const resizedDepth = resizeDepthPixels(depthPixels, oldSize, nextSize); + setupEditor(nextSize, resizedRight, null, resizedDepth); + doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; + lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); + resetEditorView(); + return true; + }); + updateSettingsSummary(); + }); + + els.assetCategory.addEventListener('change', refreshCategoryUI); + document.querySelectorAll('[data-static-kind]').forEach((button) => { + button.addEventListener('click', () => { + staticKind = button.dataset.staticKind; + document.querySelectorAll('[data-static-kind]').forEach((b) => b.classList.toggle('active', b === button)); + refreshCategoryUI(); + }); + }); + document.querySelectorAll('[data-dynamic-kind]').forEach((button) => { + button.addEventListener('click', () => { + dynamicKind = button.dataset.dynamicKind; + document.querySelectorAll('[data-dynamic-kind]').forEach((b) => b.classList.toggle('active', b === button)); + refreshCategoryUI(); + }); + }); + + els.editRight.addEventListener('click', () => setEditingSide('right')); + els.editLeft.addEventListener('click', () => setEditingSide('left')); + + els.toolBrush.addEventListener('click', () => setPaintTool('brush')); + els.toolErase.addEventListener('click', () => setPaintTool('erase')); + els.toolFill?.addEventListener('click', () => setPaintTool('fill')); + els.toolPick?.addEventListener('click', () => setPaintTool('pick')); + els.toolLine?.addEventListener('click', () => setPaintTool('line')); + els.toolRect?.addEventListener('click', () => setPaintTool('rect')); + els.toolSelect?.addEventListener('click', () => setPaintTool('select')); + els.toolLight.addEventListener('click', () => setPaintTool('light')); + els.toolDoor.addEventListener('click', () => setPaintTool('door')); + els.toolDepth?.addEventListener('click', () => setPaintTool('depth')); + els.toggleAdvanced?.addEventListener('click', () => { + advancedDraw = !advancedDraw; + toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolDepth, !advancedDraw); + toggleHidden(els.advancedHint, !advancedDraw); + els.toggleAdvanced.classList.toggle('active', advancedDraw); + if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush'); + drawEditor(); + }); + els.clearPaint.addEventListener('click', () => { + commitEditorMutation(() => { + editorPixels = blankPixels(editorSize); + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + depthPixels = blankPixels(editorSize).map(() => 0); + lightPixels = []; + editorSelection = null; + return true; + }); + }); + els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); + els.flipVertical?.addEventListener('click', flipEditorVertical); + els.outlinePaint?.addEventListener('click', applyEditorOutline); + els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); }); + els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0)); + els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0)); + els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1)); + els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1)); + els.exportPng?.addEventListener('click', exportEditorPng); + els.importPng?.addEventListener('click', () => els.pngImportInput?.click()); + els.pngImportInput?.addEventListener('change', importEditorPng); + els.undoPaint?.addEventListener('click', undoEditor); + els.redoPaint?.addEventListener('click', redoEditor); + window.addEventListener('keydown', onEditorKeyDown); + els.voteUp?.addEventListener('click', () => voteSelected(1)); + els.voteDown?.addEventListener('click', () => voteSelected(-1)); + els.bubbleRemix?.addEventListener('click', () => remixSelected()); + els.bubbleHide?.addEventListener('click', () => hideSelected()); + + els.paintCanvas.addEventListener('pointerdown', onPaintPointerDown); + els.paintCanvas.addEventListener('pointermove', onPaintPointerMove); + els.paintCanvas.addEventListener('pointerup', onPaintPointerUp); + els.paintCanvas.addEventListener('pointercancel', onPaintPointerUp); + els.paintCanvas.addEventListener('mousedown', onPaintMouseDown); + window.addEventListener('mousemove', onPaintMouseMove); + window.addEventListener('mouseup', onPaintMouseUp); + els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false }); + els.paintCanvas.addEventListener('click', onPaintClick); + window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; finishEditorGesture(); }); + els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault()); + + els.saveAsset.addEventListener('click', saveAssetFromEditor); + els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); + els.newAsset.addEventListener('click', newAsset); + els.exportData.addEventListener('click', exportData); + els.importData.addEventListener('click', importData); + els.exportCompact?.addEventListener('click', exportCompactData); + els.exportSnapshot?.addEventListener('click', exportSnapshotData); + els.exportAssetBundle?.addEventListener('click', exportAssetBundleData); + els.resetAll.addEventListener('click', resetAll); + els.showHiddenAssets?.addEventListener('click', () => { + els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden; + renderHiddenAssets(); + }); + } + + function resizeCanvas() { + dpr = window.devicePixelRatio || 1; + cw = Math.max(1, window.innerWidth); + ch = Math.max(1, window.innerHeight); + els.canvas.width = Math.floor(cw * dpr); + els.canvas.height = Math.floor(ch * dpr); + els.canvas.style.width = `${cw}px`; + els.canvas.style.height = `${ch}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + function resetView(announce) { + const bounds = terrainCache.bounds; + view.zoom = Math.min(1.05, Math.max(MIN_ZOOM, Math.min(cw / bounds.w, ch / bounds.h) * 1.18)); + view.x = cw / 2 - (bounds.x + bounds.w / 2) * view.zoom; + view.y = ch / 2 - (bounds.y + bounds.h / 2) * view.zoom + 34; + if (announce) toast('View reset.'); + } + + function setDrawerOpen(open) { + els.drawer.classList.toggle('open', open); + els.openEditor.hidden = open; + if (!open) { + selectedObject = null; + updateSelectionBubble(performance.now()); + } + } + + function onDocumentContextMenu(event) { + if (!els.drawer?.classList.contains('open')) return; + if (els.drawer.contains(event.target) || els.openEditor.contains(event.target)) return; + event.preventDefault(); + setDrawerOpen(false); + } + + function setTab(name) { + els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name)); + els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`)); + } + + function setMode(nextMode) { + mode = nextMode; + const isInspect = mode === 'inspect'; + const isPlace = mode === 'place'; + const isErase = mode === 'erase'; + [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.classList.toggle('active', isInspect)); + [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.classList.toggle('active', isPlace)); + [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.classList.toggle('active', isErase)); + els.canvas.classList.toggle('placeCursor', isPlace); + els.canvas.classList.toggle('eraseCursor', isErase); + } + + function refreshCategoryUI() { + const role = currentRole(); + const isStatic = roleToCategory(role) === 'static'; + if (isStatic) staticKind = roleToSubtype(role); + else dynamicKind = roleToSubtype(role); + + toggleHidden(els.staticKindWrap, true); + toggleHidden(els.dynamicKindWrap, true); + toggleHidden(els.sideSwitcher, isStatic); + toggleHidden(els.staticSettingsPanel, !isStatic); + toggleHidden(els.dynamicSettingsPanel, true); + toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolDepth, !advancedDraw); + toggleHidden(els.toolDoor, role !== 'building'); + toggleHidden(els.doorMarkerHint, role !== 'building'); + + if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth')) setPaintTool('brush'); + if (!isStatic && paintTool === 'door') setPaintTool('brush'); + if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush'); + if (isStatic) editingSide = 'right'; + + updateRoleHint(); + updateSideButtons(); + clampEditorView(); + drawEditor(); + updateSettingsSummary(); + } + + function updateRoleHint() { + if (!els.roleHint) return; + const role = currentRole(); + const textMap = { + human: 'Humans need ▶ Right and ◀ Left sprites. They hop often and visit buildings.', + animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.', + nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.', + building: 'Buildings attract humans. Use Door to mark the entrance.', + other: 'Other objects are neutral scenery and render at 2× scale.' + }; + els.roleHint.textContent = textMap[role] || textMap.other; + } + + function updateSettingsSummary() { + if (!els.settingsSummary) return; + const role = currentRole(); + if (roleToCategory(role) === 'static') { + const lightCount = lightPixels.length; + const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light'; + const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; + els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`; + } else { + els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`; + } + } + + function setPaintTool(tool) { + paintTool = tool; + [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); + ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); + const hints = { + brush: 'Draw pixels with the selected palette color.', + erase: 'Erase pixels. Erasing also clears light markers on that cell.', + fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.', + pick: 'Pick a color from the canvas and return to Draw.', + line: 'Drag to draw a straight line. Right-click erases along the line.', + rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', + select: 'Drag a rectangle to select pixels, then drag or nudge the selection.', + light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', + door: 'Click one pixel to mark a building door. Humans will enter near this point.', + depth: 'Advanced: paint depth values onto pixels. Shift/right-click erases depth.' + }; + els.editHint.textContent = hints[tool] || hints.brush; + } + + function setEditingSide(side) { + if (roleToCategory(currentRole()) !== 'dynamic') return; + editingSide = side; + updateSideButtons(); + drawEditor(); + } + + function updateSideButtons() { + if (els.editRight) els.editRight.classList.toggle('active', editingSide === 'right'); + if (els.editLeft) els.editLeft.classList.toggle('active', editingSide === 'left'); + updateSettingsSummary(); + } + + function onWorldPointerDown(event) { + event.preventDefault(); + els.canvas.setPointerCapture(event.pointerId); + pointer = { + down: true, + id: event.pointerId, + startX: event.clientX, + startY: event.clientY, + lastX: event.clientX, + lastY: event.clientY, + dragging: false, + downTime: performance.now() + }; + els.canvas.classList.add('dragging'); + } + + function onWorldPointerMove(event) { + const pos = getCanvasPoint(event); + hoverTile = screenToTile(pos.x, pos.y); + updateTileInfo(); + + if (!pointer.down || pointer.id !== event.pointerId) return; + const dx = event.clientX - pointer.lastX; + const dy = event.clientY - pointer.lastY; + const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); + if (total > 3) pointer.dragging = true; + if (pointer.dragging) { + view.x += dx; + view.y += dy; + } + pointer.lastX = event.clientX; + pointer.lastY = event.clientY; + } + + function onWorldPointerUp(event) { + if (!pointer.down || pointer.id !== event.pointerId) return; + els.canvas.classList.remove('dragging'); + const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); + const elapsed = performance.now() - pointer.downTime; + const wasClick = total < 6 && elapsed < 600; + const pos = getCanvasPoint(event); + const tile = screenToTile(pos.x, pos.y); + if (wasClick) { + const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now()); + if (mode === 'place' && tile) placeSelected(tile.x, tile.y); + else if (mode === 'erase') { + if (pickedObject) eraseObject(pickedObject.kind, pickedObject.id); + else if (tile) eraseAt(tile.x, tile.y); + } else if (pickedObject) { + selectWorldObject(pickedObject.kind, pickedObject.id, pickedObject.assetId, performance.now()); + const asset = findAsset(pickedObject.assetId); + toast(asset ? `Selected ${asset.name}.` : 'Selected object.'); + } else if (tile) inspectAt(tile.x, tile.y); + } + pointer.down = false; + pointer.id = null; + pointer.dragging = false; + } + + function onWorldWheel(event) { + event.preventDefault(); + const pos = getCanvasPoint(event); + const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13; + zoomAt(pos.x, pos.y, view.zoom * factor); + } + + function zoomAt(screenX, screenY, nextZoom) { + nextZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM); + const worldX = (screenX - view.x) / view.zoom; + const worldY = (screenY - view.y) / view.zoom; + view.zoom = nextZoom; + view.x = screenX - worldX * view.zoom; + view.y = screenY - worldY * view.zoom; + } + + function getCanvasPoint(event) { + const rect = els.canvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + } + + function screenToTile(screenX, screenY) { + const worldX = (screenX - view.x) / view.zoom; + const worldY = (screenY - view.y) / view.zoom; + const localX = worldX - ORIGIN_X; + const localY = worldY - ORIGIN_Y; + const a = localX / (TILE_W / 2); + const b = localY / (TILE_H / 2); + const baseX = Math.floor((a + b) / 2); + const baseY = Math.floor((b - a) / 2); + let best = null; + let bestScore = -Infinity; + for (let ty = baseY - 2; ty <= baseY + 2; ty++) { + for (let tx = baseX - 2; tx <= baseX + 2; tx++) { + if (tx < 0 || ty < 0 || tx >= WORLD_W || ty >= WORLD_H) continue; + const tile = world.get(tx, ty); + const center = tileToWorld(tx, ty); + const lift = getTileLift(tile); + const nx = Math.abs(worldX - center.x) / (TILE_W / 2); + const ny = Math.abs(worldY - (center.y + TILE_H / 2 - lift)) / (TILE_H / 2); + if (nx + ny <= 1) { + const score = lift * 100 + (tx + ty); + if (score > bestScore) { + bestScore = score; + best = { x: tx, y: ty, tile }; + } + } + } + } + return best; + } + + function tileToWorld(x, y) { + return { + x: (x - y) * TILE_W / 2 + ORIGIN_X, + y: (x + y) * TILE_H / 2 + ORIGIN_Y + }; + } + + function tileKey(x, y) { + return `${x},${y}`; + } + + function makeEmptyWorldIndex() { + return { placedByTile: new Map(), dynamicByHomeTile: new Map() }; + } + + function pushIndexBucket(map, key, item) { + const bucket = map.get(key); + if (bucket) bucket.push(item); + else map.set(key, [item]); + } + + function rebuildWorldIndex() { + const next = makeEmptyWorldIndex(); + for (const placed of state.placed || []) { + pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed); + } + for (const summon of state.dynamicSummons || []) { + pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon); + } + worldIndex = next; + } + + function getPlacedAtTile(x, y) { + return worldIndex.placedByTile.get(tileKey(x, y)) || []; + } + + function getDynamicHomesAtTile(x, y) { + return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || []; + } + + + function getDrawableItems(time = performance.now(), viewport = null) { + const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); + const items = []; + for (const placed of state.placed) { + const asset = findAsset(placed.assetId); + if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue; + const itemX = placed.x + .5; + const itemY = placed.y + .5; + if (!isApproxVisible(asset, itemX, itemY, rect)) continue; + items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed }); + } + for (const runtime of dynamicRuntime) { + const asset = findAsset(runtime.assetId); + if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue; + if (time < runtime.hiddenUntil) continue; + if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; + items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); + } + items.sort(drawOrderCompare); + return items; + } + + function drawOrderCompare(a, b) { + return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || '')); + } + + function getViewportWorldRect(margin = 0) { + const invZoom = 1 / Math.max(0.0001, view.zoom); + return { + left: -view.x * invZoom - margin, + top: -view.y * invZoom - margin, + right: (cw - view.x) * invZoom + margin, + bottom: (ch - view.y) * invZoom + margin + }; + } + + function isApproxVisible(asset, x, y, rect) { + if (!rect) return true; + const pos = tileToWorld(x, y); + if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y); + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const w = asset.size * scale + 96; + const h = asset.size * scale + 128; + return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom; + } + + + function pickObjectAtScreen(screenX, screenY, time = performance.now()) { + const worldX = (screenX - view.x) / view.zoom; + const worldY = (screenY - view.y) / view.zoom; + const items = getDrawableItems(time, getViewportWorldRect(256)); + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + const info = getSpriteDrawInfo(item, time, false); + const margin = item.kind === 'dynamic' ? 18 : 8; + const left = info.drawX - margin; + const top = info.drawY - margin; + const right = info.drawX + info.sprite.width + margin; + const bottom = info.drawY + info.sprite.height + margin; + if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) { + return { kind: item.kind, id: item.source.id, assetId: item.asset.id }; + } + } + return null; + } + + function updateTileInfo() { + if (!els.tileInfo) return; + if (!hoverTile) { + els.tileInfo.textContent = 'Move over the map.'; + return; + } + const tile = world.get(hoverTile.x, hoverTile.y); + const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length; + const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length; + els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`; + } + + function inspectAt(x, y) { + const tile = world.get(x, y); + const staticObjects = getPlacedAtTile(x, y); + const dynamicObjects = getDynamicHomesAtTile(x, y); + const picked = [...dynamicObjects.map((p) => ({ ...p, objectKind: 'dynamic' })), ...staticObjects.map((p) => ({ ...p, objectKind: 'static' }))].at(-1); + if (picked) { + selectWorldObject(picked.objectKind, picked.id, picked.assetId, performance.now()); + const asset = findAsset(picked.assetId); + toast(asset ? `Selected ${asset.name}.` : 'Selected object.'); + } else { + selectedObject = null; + updateSelectionBubble(performance.now()); + } + const staticNames = staticObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); + const dynamicNames = dynamicObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); + const lines = [`Tile ${x}, ${y}`, `Terrain: ${cap(tile.type)}`]; + if (staticNames.length) lines.push(`Static: ${staticNames.join(', ')}`); + if (dynamicNames.length) lines.push(`Dynamic homes: ${dynamicNames.join(', ')}`); + if (els.tileInfo) els.tileInfo.textContent = lines.join('\n'); + } + + function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) { + selectedObject = { kind, id: objectId, assetId, selectedAt }; + selectedAssetId = assetId; + updateSelectedLabel(); + renderLibrary(); + updateSelectionBubble(selectedAt); + } + + function placeSelected(x, y) { + const asset = findAsset(selectedAssetId); + if (!asset) { + toast('Select an asset first.'); + setDrawerOpen(true); + setTab('library'); + return; + } + const tile = world.get(x, y); + if (!canPlace(asset, tile)) return; + + if (asset.category === 'static') { + const existing = state.placed.find((p) => p.assetId === asset.id); + if (existing) { + existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1; + selectWorldObject('static', existing.id, asset.id, performance.now()); + toast(`${asset.name} moved.`); + } else { + const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 }; + state.placed.push(placed); + selectWorldObject('static', placed.id, asset.id, performance.now()); + toast(`${asset.name} placed.`); + } + } else { + const existing = state.dynamicSummons.find((p) => p.assetId === asset.id); + if (existing) { + existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1; + toast(`${asset.name} moved.`); + } else { + const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), version: 1 }; + state.dynamicSummons.push(summon); + toast(`${asset.name} summoned.`); + } + hydrateRuntime(); + const found = state.dynamicSummons.find((p) => p.assetId === asset.id); + if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now()); + } + const object = asset.category === 'static' + ? state.placed.find((p) => p.assetId === asset.id) + : state.dynamicSummons.find((p) => p.assetId === asset.id); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object)); + spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); + saveState(); + } + + function canPlace(asset, tile) { + if (!tile) return false; + if (asset.category === 'static' && asset.subtype === 'water' && tile.type !== 'water') { + toast('Water objects need water tiles.'); + return false; + } + if (asset.category === 'dynamic' && asset.subtype === 'fish' && tile.type !== 'water') { + toast('Fish need water tiles.'); + return false; + } + if (asset.category !== 'dynamic' || asset.subtype !== 'fish') { + if (tile.type === 'water' && !(asset.category === 'static' && asset.subtype === 'water')) { + toast('Use land for this asset.'); + return false; + } + } + return true; + } + + function eraseAt(x, y) { + const staticObjects = getPlacedAtTile(x, y); + const staticTarget = staticObjects.at(-1); + if (staticTarget) { + const index = state.placed.findIndex((p) => p.id === staticTarget.id); + if (index >= 0) { + const item = state.placed[index]; + const asset = findAsset(item.assetId); + state.placed.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id)); + if (selectedObject?.id === item.id) selectedObject = null; + saveState(); + toast(`${asset?.name ?? 'Object'} removed.`); + return; + } + } + const dynamicObjects = getDynamicHomesAtTile(x, y); + const dynamicTarget = dynamicObjects.at(-1); + if (dynamicTarget) { + const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id); + if (index >= 0) { + const item = state.dynamicSummons[index]; + const asset = findAsset(item.assetId); + state.dynamicSummons.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id)); + if (selectedObject?.id === item.id) selectedObject = null; + hydrateRuntime(); + saveState(); + toast(`${asset?.name ?? 'Dynamic object'} removed.`); + return; + } + } + toast('Nothing to erase here.'); + } + + + + function eraseObject(kind, objectId) { + if (kind === 'static') { + const index = state.placed.findIndex((p) => p.id === objectId); + if (index >= 0) { + const asset = findAsset(state.placed[index].assetId); + state.placed.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId)); + if (selectedObject?.id === objectId) selectedObject = null; + saveState(); + updateSelectionBubble(performance.now()); + toast(`${asset?.name ?? 'Object'} removed.`); + return; + } + } + if (kind === 'dynamic') { + const index = state.dynamicSummons.findIndex((p) => p.id === objectId); + if (index >= 0) { + const asset = findAsset(state.dynamicSummons[index].assetId); + state.dynamicSummons.splice(index, 1); + rebuildWorldIndex(); + recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId)); + if (selectedObject?.id === objectId) selectedObject = null; + hydrateRuntime(); + saveState(); + updateSelectionBubble(performance.now()); + toast(`${asset?.name ?? 'Dynamic object'} removed.`); + } + } + } + + function onPaintPointerDown(event) { + event.preventDefault(); + els.paintCanvas.setPointerCapture?.(event.pointerId); + lastPaintedKey = ''; + const isPan = event.button === 1 || event.button === 2; + editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY }; + isPainting = !isPan; + mousePaint.active = false; + mousePaint.panning = false; + if (isPainting) { + suppressNextPaintClick = true; + suppressMousePaintUntil = performance.now() + 350; + if (handleEditorSpecialPointerDown(event)) return; + beginEditorGesture(); + paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); + if (paintTool === 'fill' || paintTool === 'pick') { isPainting = false; finishEditorGesture(); } + } + } + + function onPaintPointerMove(event) { + if (editorPointer.pointerId !== event.pointerId) return; + if (editorPointer.panning) { + event.preventDefault(); + const rect = els.paintCanvas.getBoundingClientRect(); + const sx = els.paintCanvas.width / rect.width; + const sy = els.paintCanvas.height / rect.height; + editorView.x += (event.clientX - editorPointer.lastX) * sx; + editorView.y += (event.clientY - editorPointer.lastY) * sy; + editorPointer.lastX = event.clientX; + editorPointer.lastY = event.clientY; + clampEditorView(); + drawEditor(); + return; + } + if (shapeGesture || selectionGesture) { + event.preventDefault(); + handleEditorSpecialPointerMove(event); + return; + } + if (!isPainting) return; + if (event.buttons !== undefined && (event.buttons & 1) === 0) return; + event.preventDefault(); + paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); + } + + function onPaintPointerUp(event) { + if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false; + if (shapeGesture || selectionGesture) handleEditorSpecialPointerUp(event); + isPainting = false; + lastPaintedKey = ''; + finishEditorGesture(); + } + + function onPaintClick(event) { + if (event.button !== 0) return; + if (suppressNextPaintClick) { suppressNextPaintClick = false; return; } + event.preventDefault(); + lastPaintedKey = ''; + if (paintTool === 'line' || paintTool === 'rect' || paintTool === 'select') return; + beginEditorGesture(); + paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); + finishEditorGesture(); + } + + function onPaintWheel(event) { + event.preventDefault(); + const rect = els.paintCanvas.getBoundingClientRect(); + const scaleX = els.paintCanvas.width / rect.width; + const scaleY = els.paintCanvas.height / rect.height; + const sx = (event.clientX - rect.left) * scaleX; + const sy = (event.clientY - rect.top) * scaleY; + const worldX = (sx - editorView.x) / editorView.zoom; + const worldY = (sy - editorView.y) / editorView.zoom; + const nextZoom = clamp(editorView.zoom * (event.deltaY > 0 ? 1 / 1.12 : 1.12), 1, 24); + editorView.zoom = nextZoom; + editorView.x = sx - worldX * editorView.zoom; + editorView.y = sy - worldY * editorView.zoom; + clampEditorView(); + drawEditor(); + } + + function onPaintMouseDown(event) { + // Fallback for browsers/extensions where pointer events are swallowed. + if (event.button !== 0 && event.button !== 1 && event.button !== 2) return; + event.preventDefault(); + lastPaintedKey = ''; + const isPan = event.button === 1 || event.button === 2; + mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button }; + if (performance.now() < suppressMousePaintUntil) return; + if (mousePaint.active) { + beginEditorGesture(); + paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey); + if (paintTool === 'fill' || paintTool === 'pick') { mousePaint.active = false; finishEditorGesture(); } + } + } + + function onPaintMouseMove(event) { + if (!mousePaint.active && !mousePaint.panning) return; + event.preventDefault(); + if (mousePaint.panning) { + const rect = els.paintCanvas.getBoundingClientRect(); + const sx = els.paintCanvas.width / rect.width; + const sy = els.paintCanvas.height / rect.height; + editorView.x += (event.clientX - mousePaint.lastX) * sx; + editorView.y += (event.clientY - mousePaint.lastY) * sy; + mousePaint.lastX = event.clientX; + mousePaint.lastY = event.clientY; + clampEditorView(); + drawEditor(); + return; + } + paintAtClient(event.clientX, event.clientY, mousePaint.button || 0, event.shiftKey); + } + + function onPaintMouseUp() { + mousePaint.active = false; + mousePaint.panning = false; + lastPaintedKey = ''; + finishEditorGesture(); + } + + function paintAtEvent(event) { + paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); + } + + function paintAtClient(clientX, clientY, button = 0, shiftKey = false) { + const local = clientToPaintLocal(clientX, clientY); + const x = Math.floor(local.x); + const y = Math.floor(local.y); + if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return; + const key = `${x},${y},${paintTool},${button},${shiftKey}`; + if (key === lastPaintedKey && paintTool !== 'fill') return; + lastPaintedKey = key; + + const point = displayCellToCanonical(x, y); + const index = point.y * editorSize + point.x; + let changed = false; + + if (paintTool === 'pick') { + const picked = editorPixels[index]; + if (picked) selectPaletteCode(picked); + setPaintTool('brush'); + drawEditor(); + return; + } + + if (paintTool === 'fill') { + const displayPixels = getDisplayEditorPixels(); + const fillColor = shiftKey ? null : selectedColorCode; + const nextDisplay = EditorActions?.floodFill + ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor) + : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); + if (nextDisplay.changed) { + editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorPixels(nextDisplay.pixels, editorSize) + : nextDisplay.pixels; + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + if (fillColor === null && Array.isArray(nextDisplay.cells)) { + for (const cell of nextDisplay.cells) { + const src = displayCellToCanonical(cell.x, cell.y); + removeLightPixel(src.x, src.y); + } + } + changed = true; + } + } else if (paintTool === 'brush') { + if (editorPixels[index] !== selectedColorCode) { + editorPixels[index] = selectedColorCode; + changed = true; + } + } else if (paintTool === 'erase') { + if (editorPixels[index] !== null) { + editorPixels[index] = null; + changed = true; + } + const before = lightPixels.length; + removeLightPixel(point.x, point.y); + changed = changed || before !== lightPixels.length; + } else if (paintTool === 'light') { + const before = JSON.stringify(lightPixels); + if (shiftKey || button === 2) removeLightPixel(point.x, point.y); + else addLightPixel(point.x, point.y, selectedColorCode); + changed = JSON.stringify(lightPixels) !== before; + updateSettingsSummary(); + } else if (paintTool === 'depth') { + const next = (shiftKey || button === 2) ? 0 : 1; + if (depthPixels[index] !== next) { + depthPixels[index] = next; + changed = true; + } + } else if (paintTool === 'door') { + if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') { + if (doorPixel.x !== x || doorPixel.y !== y) { + doorPixel = { x, y }; + changed = true; + } + updateSettingsSummary(); + } + } + + if (changed) { + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + markEditorChanged(); + drawEditor(); + } + } + + function paintEventToCell(event) { + const local = paintEventToLocal(event); + return { x: Math.floor(local.x), y: Math.floor(local.y) }; + } + + function paintEventToLocal(event) { + return clientToPaintLocal(event.clientX, event.clientY); + } + + function clientToPaintLocal(clientX, clientY) { + const rect = els.paintCanvas.getBoundingClientRect(); + const scaleX = els.paintCanvas.width / rect.width; + const scaleY = els.paintCanvas.height / rect.height; + const sx = (clientX - rect.left) * scaleX; + const sy = (clientY - rect.top) * scaleY; + const cell = els.paintCanvas.width / editorSize; + return { + x: (sx - editorView.x) / editorView.zoom / cell, + y: (sy - editorView.y) / editorView.zoom / cell + }; + } + + function snapshotEditorState() { + return { + size: editorSize, + rightPixels: [...editorPixels], + depthPixels: [...depthPixels], + lightPixels: lightPixels.map((p) => ({ ...p })), + doorPixel: { ...doorPixel }, + editingSide, + selectedColorCode, + editorSelection: editorSelection ? { ...editorSelection } : null + }; + } + + function restoreEditorState(snapshot) { + editorSize = snapshot.size || editorSize; + editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize); + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); + lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; + doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; + editingSide = snapshot.editingSide || editingSide; + if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; + editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null; + els.assetSize.value = String(editorSize); + updateSideButtons(); + renderPalette(); + drawEditor(); + updateSettingsSummary(); + updateEditorHistoryButtons(); + updateEditorSelectionButtons(); + } + + function beginEditorGesture() { + if (!editorGestureSnapshot) { + editorGestureSnapshot = snapshotEditorState(); + editorGestureChanged = false; + } + } + + function markEditorChanged() { + editorGestureChanged = true; + } + + function finishEditorGesture() { + if (!editorGestureSnapshot) return; + if (editorGestureChanged) { + editorHistory.push(editorGestureSnapshot); + if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); + editorFuture = []; + } + editorGestureSnapshot = null; + editorGestureChanged = false; + updateEditorHistoryButtons(); + } + + function commitEditorMutation(mutator) { + const before = snapshotEditorState(); + const changed = Boolean(mutator()); + if (changed) { + editorHistory.push(before); + if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); + editorFuture = []; + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + drawEditor(); + updateSettingsSummary(); + updateEditorHistoryButtons(); + } + return changed; + } + + function clearEditorHistory() { + editorHistory = []; + editorFuture = []; + editorGestureSnapshot = null; + editorGestureChanged = false; + updateEditorHistoryButtons(); + } + + function undoEditor() { + if (!editorHistory.length) return; + editorFuture.push(snapshotEditorState()); + restoreEditorState(editorHistory.pop()); + } + + function redoEditor() { + if (!editorFuture.length) return; + editorHistory.push(snapshotEditorState()); + restoreEditorState(editorFuture.pop()); + } + + function updateEditorHistoryButtons() { + if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0; + if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0; + updateEditorSelectionButtons(); + } + + function onEditorKeyDown(event) { + if (!els.drawer?.classList.contains('open')) return; + const key = event.key.toLowerCase(); + const activeTag = document.activeElement?.tagName?.toLowerCase(); + if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return; + if ((event.ctrlKey || event.metaKey) && key === 'z') { + event.preventDefault(); + if (event.shiftKey) redoEditor(); + else undoEditor(); + } else if ((event.ctrlKey || event.metaKey) && key === 'y') { + event.preventDefault(); + redoEditor(); + } else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) { + event.preventDefault(); + const step = event.shiftKey ? 4 : 1; + const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key]; + moveSelectionBy(delta[0], delta[1]); + } else if (key === 'escape') { + editorSelection = null; + drawEditor(); + updateEditorSelectionButtons(); + } else if (!event.ctrlKey && !event.metaKey && !event.altKey) { + const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' }; + if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); } + } + } + + function fallbackFloodFill(sourcePixels, size, x, y, colorCode) { + const pixels = [...sourcePixels]; + const target = pixels[y * size + x] || null; + const replacement = colorCode || null; + if (target === replacement) return { pixels, changed: false, count: 0, cells: [] }; + const stack = [[x, y]]; + const cells = []; + while (stack.length) { + const [cx, cy] = stack.pop(); + if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue; + const index = cy * size + cx; + if ((pixels[index] || null) !== target) continue; + pixels[index] = replacement; + cells.push({ x: cx, y: cy }); + stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + } + return { pixels, changed: cells.length > 0, count: cells.length, cells }; + } + + + function handleEditorSpecialPointerDown(event) { + const cell = clientToCell(event.clientX, event.clientY); + if (!cell) return false; + if (paintTool === 'line' || paintTool === 'rect') { + beginEditorGesture(); + shapeGesture = { + tool: paintTool, + startX: cell.x, + startY: cell.y, + endX: cell.x, + endY: cell.y, + erase: event.button === 2, + filled: event.shiftKey + }; + shapePreview = { ...shapeGesture }; + drawEditor(); + return true; + } + if (paintTool === 'select') { + const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection); + if (inside) { + beginEditorGesture(); + const baseSelection = { ...editorSelection }; + selectionGesture = { + mode: 'move', + startX: cell.x, + startY: cell.y, + lastDx: 0, + lastDy: 0, + baseSelection, + basePixels: getDisplayEditorPixels(), + baseDepthPixels: getDisplayDepthPixels(), + baseLightPixels: lightPixels.map((p) => ({ ...p })) + }; + } else { + selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y }; + editorSelection = normalizeSelectionRect(cell.x, cell.y, cell.x, cell.y); + updateEditorSelectionButtons(); + drawEditor(); + } + return true; + } + return false; + } + + function handleEditorSpecialPointerMove(event) { + const cell = clientToCell(event.clientX, event.clientY); + if (!cell) return; + if (shapeGesture) { + shapeGesture.endX = cell.x; + shapeGesture.endY = cell.y; + shapeGesture.filled = event.shiftKey; + shapePreview = { ...shapeGesture }; + drawEditor(); + return; + } + if (selectionGesture?.mode === 'select') { + selectionGesture.endX = cell.x; + selectionGesture.endY = cell.y; + editorSelection = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, cell.x, cell.y); + updateEditorSelectionButtons(); + drawEditor(); + return; + } + if (selectionGesture?.mode === 'move') { + let dx = cell.x - selectionGesture.startX; + let dy = cell.y - selectionGesture.startY; + ({ dx, dy } = clampSelectionDelta(selectionGesture.baseSelection, dx, dy)); + if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return; + selectionGesture.lastDx = dx; + selectionGesture.lastDy = dy; + applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy); + markEditorChanged(); + drawEditor(); + } + } + + function handleEditorSpecialPointerUp(event) { + if (shapeGesture) { + const changed = commitShapeGesture(shapeGesture); + if (changed) markEditorChanged(); + shapeGesture = null; + shapePreview = null; + drawEditor(); + return; + } + if (selectionGesture?.mode === 'select') { + const rect = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, selectionGesture.endX, selectionGesture.endY); + editorSelection = rect && rect.w * rect.h > 0 ? rect : null; + selectionGesture = null; + drawEditor(); + updateEditorSelectionButtons(); + return; + } + if (selectionGesture?.mode === 'move') { + selectionGesture = null; + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + updateEditorSelectionButtons(); + return; + } + } + + function clientToCell(clientX, clientY) { + const local = clientToPaintLocal(clientX, clientY); + const x = Math.floor(local.x); + const y = Math.floor(local.y); + if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return null; + return { x, y }; + } + + function normalizeSelectionRect(x0, y0, x1, y1) { + const left = clamp(Math.min(x0, x1), 0, editorSize - 1); + const top = clamp(Math.min(y0, y1), 0, editorSize - 1); + const right = clamp(Math.max(x0, x1), 0, editorSize - 1); + const bottom = clamp(Math.max(y0, y1), 0, editorSize - 1); + return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 }; + } + + function pointInSelection(x, y, selection) { + return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h; + } + + function clampSelectionDelta(selection, dx, dy) { + if (!selection) return { dx: 0, dy: 0 }; + return { + dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)), + dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h)) + }; + } + + function getDisplayDepthPixels() { + const source = normalizeDepthPixels(depthPixels, editorSize); + return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorScalarPixels(source, editorSize) + : source; + } + + function setDisplayEditorPixels(pixels) { + editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorPixels(pixels, editorSize) + : normalizePixels(pixels, editorSize); + editorLeftPixels = mirrorPixels(editorPixels, editorSize); + } + + function setDisplayDepthPixels(pixels) { + depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorScalarPixels(pixels, editorSize) + : normalizeDepthPixels(pixels, editorSize); + } + + function mirrorScalarPixels(values, size) { + const source = Array.isArray(values) ? values : []; + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + out[y * size + (size - 1 - x)] = source[y * size + x] || 0; + } + } + return out; + } + + function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) { + const nextPixels = normalizePixels(basePixels, editorSize); + const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize); + const movedPixels = [...nextPixels]; + const movedDepth = [...nextDepth]; + for (let y = selection.y; y < selection.y + selection.h; y++) { + for (let x = selection.x; x < selection.x + selection.w; x++) { + const index = y * editorSize + x; + movedPixels[index] = null; + movedDepth[index] = 0; + } + } + for (let y = selection.y; y < selection.y + selection.h; y++) { + for (let x = selection.x; x < selection.x + selection.w; x++) { + const from = y * editorSize + x; + const to = (y + dy) * editorSize + (x + dx); + movedPixels[to] = nextPixels[from] || null; + movedDepth[to] = nextDepth[from] || 0; + } + } + setDisplayEditorPixels(movedPixels); + setDisplayDepthPixels(movedDepth); + lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy); + editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h }; + updateEditorSelectionButtons(); + } + + function moveDisplayLights(sourceLights, selection, dx, dy) { + const out = []; + for (const light of sourceLights || []) { + const shown = canonicalCellToDisplay(light.x, light.y); + if (!pointInSelection(shown.x, shown.y, selection)) { + out.push({ ...light }); + continue; + } + const nx = shown.x + dx; + const ny = shown.y + dy; + if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue; + const canonical = displayCellToCanonical(nx, ny); + out.push({ ...light, x: canonical.x, y: canonical.y }); + } + return out; + } + + function moveSelectionBy(dx, dy) { + if (!editorSelection) return false; + const bounded = clampSelectionDelta(editorSelection, dx, dy); + if (!bounded.dx && !bounded.dy) return false; + return commitEditorMutation(() => { + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); + return true; + }); + } + + function nudgeEditor(dx, dy) { + if (editorSelection) moveSelectionBy(dx, dy); + else shiftEditorContent(dx, dy); + } + + function shiftEditorContent(dx, dy) { + return commitEditorMutation(() => { + const selection = { x: 0, y: 0, w: editorSize, h: editorSize }; + const bounded = clampSelectionDelta(selection, dx, dy); + if (!bounded.dx && !bounded.dy) return false; + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); + editorSelection = null; + doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) }; + return true; + }); + } + + function commitShapeGesture(gesture) { + const cells = gesture.tool === 'line' + ? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY) + : getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled); + if (!cells.length) return false; + const pixels = getDisplayEditorPixels(); + const value = gesture.erase ? null : selectedColorCode; + let changed = false; + for (const cell of cells) { + const index = cell.y * editorSize + cell.x; + if ((pixels[index] || null) !== value) { + pixels[index] = value; + changed = true; + } + if (gesture.erase) { + const canonical = displayCellToCanonical(cell.x, cell.y); + removeLightPixel(canonical.x, canonical.y); + } + } + if (!changed) return false; + setDisplayEditorPixels(pixels); + return true; + } + + function getLineCells(x0, y0, x1, y1) { + const cells = []; + let dx = Math.abs(x1 - x0); + let dy = -Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + let x = x0; + let y = y0; + while (true) { + cells.push({ x, y }); + if (x === x1 && y === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; x += sx; } + if (e2 <= dx) { err += dx; y += sy; } + } + return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize); + } + + function getRectCells(x0, y0, x1, y1, filled = false) { + const rect = normalizeSelectionRect(x0, y0, x1, y1); + const cells = []; + for (let y = rect.y; y < rect.y + rect.h; y++) { + for (let x = rect.x; x < rect.x + rect.w; x++) { + if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y }); + } + } + return cells; + } + + function drawEditorOverlays(cell) { + if (shapePreview) { + const cells = shapePreview.tool === 'line' + ? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY) + : getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled); + pctx.save(); + pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42; + pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode); + for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell)); + pctx.restore(); + } + if (editorSelection) { + pctx.save(); + pctx.strokeStyle = '#ff4fa3'; + pctx.lineWidth = Math.max(2, 2 / editorView.zoom); + pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]); + pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom); + pctx.fillStyle = 'rgba(255, 79, 163, .08)'; + pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell); + pctx.restore(); + } + } + + function updateEditorSelectionButtons() { + const disabled = !editorSelection; + if (els.clearSelection) els.clearSelection.disabled = disabled; + } + + function flipEditorHorizontal() { + commitEditorMutation(() => { + editorPixels = mirrorPixels(editorPixels, editorSize); + depthPixels = mirrorScalarPixels(depthPixels, editorSize); + lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); + doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x }; + editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; + return true; + }); + } + + function flipEditorVertical() { + commitEditorMutation(() => { + editorPixels = flipPixelsVertical(editorPixels, editorSize); + depthPixels = flipScalarPixelsVertical(depthPixels, editorSize); + lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); + doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y }; + editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; + return true; + }); + } + + function flipPixelsVertical(pixels, size) { + const source = normalizePixels(pixels, size); + const out = blankPixels(size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || null; + } + return out; + } + + function flipScalarPixelsVertical(values, size) { + const source = normalizeDepthPixels(values, size); + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || 0; + } + return out; + } + + function applyEditorOutline() { + commitEditorMutation(() => { + const source = [...editorPixels]; + const out = [...editorPixels]; + let changed = false; + for (let y = 0; y < editorSize; y++) { + for (let x = 0; x < editorSize; x++) { + const index = y * editorSize + x; + if (source[index]) continue; + const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => { + const nx = x + dx; + const ny = y + dy; + return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source[ny * editorSize + nx]; + }); + if (adjacent) { out[index] = selectedColorCode; changed = true; } + } + } + if (!changed) return false; + editorPixels = out; + return true; + }); + } + + function exportEditorPng() { + const scale = Math.max(8, Math.floor(512 / editorSize)); + const canvas = document.createElement('canvas'); + canvas.width = editorSize * scale; + canvas.height = editorSize * scale; + const c = canvas.getContext('2d'); + c.imageSmoothingEnabled = false; + const pixels = getDisplayEditorPixels(); + for (let y = 0; y < editorSize; y++) { + for (let x = 0; x < editorSize; x++) { + const code = pixels[y * editorSize + x]; + if (!code) continue; + c.fillStyle = colorToHex(code); + c.fillRect(x * scale, y * scale, scale, scale); + } + } + const link = document.createElement('a'); + const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art'; + link.download = `${rawName}-${editorSize}x${editorSize}.png`; + link.href = canvas.toDataURL('image/png'); + link.click(); + toast('PNG exported.'); + } + + function importEditorPng(event) { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + const img = new Image(); + img.onload = () => { + commitEditorMutation(() => { + const canvas = document.createElement('canvas'); + canvas.width = editorSize; + canvas.height = editorSize; + const c = canvas.getContext('2d', { willReadFrequently: true }); + c.imageSmoothingEnabled = false; + c.clearRect(0, 0, editorSize, editorSize); + c.drawImage(img, 0, 0, editorSize, editorSize); + const data = c.getImageData(0, 0, editorSize, editorSize).data; + const next = blankPixels(editorSize); + for (let i = 0; i < editorSize * editorSize; i++) { + const alpha = data[i * 4 + 3]; + if (alpha < 32) continue; + const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]); + next[i] = nearestPaletteCode(hex); + } + setDisplayEditorPixels(next); + editorSelection = null; + return true; + }); + URL.revokeObjectURL(img.src); + toast(`PNG imported into ${editorSize}×${editorSize}.`); + }; + img.onerror = () => toast('Could not read that PNG.'); + img.src = URL.createObjectURL(file); + } + + function rgbToHex(r, g, b) { + const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'); + return `#${part(r)}${part(g)}${part(b)}`; + } + + function selectPaletteCode(code) { + if (!PALETTE_BY_CODE[code]) return; + selectedColorCode = code; + els.paintColor.value = PALETTE_BY_CODE[code]; + renderPalette(); + } + + function getActiveEditorPixels() { + return editorPixels; + } + + function getDisplayEditorPixels() { + return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' + ? mirrorPixels(editorPixels, editorSize) + : normalizePixels(editorPixels, editorSize); + } + + function displayCellToCanonical(x, y) { + if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; + return { x, y }; + } + + function canonicalCellToDisplay(x, y) { + if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; + return { x, y }; + } + + function setupEditor(size, rightPixels, leftPixels = null, nextDepthPixels = null) { + editorSize = size; + const normalizedRight = normalizePixels(rightPixels, size); + const normalizedLeft = leftPixels ? normalizePixels(leftPixels, size) : null; + const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorPixels(normalizedLeft, size); + editorPixels = normalizePixels(canonical, size); + editorLeftPixels = mirrorPixels(editorPixels, size); + editorSelection = null; + depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size); + els.assetSize.value = String(size); + lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); + doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; + resetEditorView(); + drawEditor(); + updateEditorHistoryButtons(); + } + + function drawEditor() { + clampEditorView(); + const canvas = els.paintCanvas; + const rectSize = canvas.width; + const cell = rectSize / editorSize; + pctx.clearRect(0, 0, rectSize, rectSize); + pctx.fillStyle = '#fffaf0'; + pctx.fillRect(0, 0, rectSize, rectSize); + + pctx.save(); + pctx.translate(editorView.x, editorView.y); + pctx.scale(editorView.zoom, editorView.zoom); + + const pixels = getDisplayEditorPixels(); + for (let y = 0; y < editorSize; y++) { + for (let x = 0; x < editorSize; x++) { + const color = pixels[y * editorSize + x]; + if (!color) continue; + pctx.fillStyle = colorToHex(color); + pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell)); + } + } + + if (advancedDraw && depthPixels?.length) { + if (paintTool === 'depth') { + pctx.fillStyle = 'rgba(46, 89, 160, .07)'; + pctx.fillRect(0, 0, rectSize, rectSize); + } + for (let y = 0; y < editorSize; y++) { + for (let x = 0; x < editorSize; x++) { + const source = displayCellToCanonical(x, y); + const depth = depthPixels[source.y * editorSize + source.x] || 0; + if (!depth) continue; + pctx.fillStyle = 'rgba(34, 116, 255, .62)'; + pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); + pctx.strokeStyle = 'rgba(6, 28, 75, .55)'; + pctx.lineWidth = Math.max(1, 2 / editorView.zoom); + pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); + } + } + } + + pctx.strokeStyle = 'rgba(36, 48, 68, .13)'; + pctx.lineWidth = 1 / editorView.zoom; + for (let i = 0; i <= editorSize; i++) { + const p = Math.round(i * cell) + .5; + pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, rectSize); pctx.stroke(); + pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(rectSize, p); pctx.stroke(); + } + + { + for (const light of lightPixels) { + const shown = canonicalCellToDisplay(light.x, light.y); + const lx = (shown.x + .5) * cell; + const ly = (shown.y + .5) * cell; + pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28); + pctx.beginPath(); + pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2); + pctx.fill(); + pctx.strokeStyle = colorToHex(light.c || selectedColorCode); + pctx.lineWidth = 2 / editorView.zoom; + pctx.beginPath(); + pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2); + pctx.stroke(); + } + if (currentRole() === 'building') { + pctx.strokeStyle = '#5fb8ff'; + pctx.lineWidth = 3 / editorView.zoom; + pctx.strokeRect(doorPixel.x * cell + 2 / editorView.zoom, doorPixel.y * cell + 2 / editorView.zoom, cell - 4 / editorView.zoom, cell - 4 / editorView.zoom); + } + } + drawEditorOverlays(cell); + pctx.restore(); + } + + function resetEditorView() { + editorView.zoom = 1; + editorView.x = 0; + editorView.y = 0; + clampEditorView(); + } + + function clampEditorView() { + const size = els.paintCanvas.width; + const scaled = size * editorView.zoom; + const minOffset = size - scaled; + editorView.x = clamp(editorView.x, minOffset, 0); + editorView.y = clamp(editorView.y, minOffset, 0); + } + + function addLightPixel(x, y, colorCode = selectedColorCode) { + const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode }; + const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y); + if (existing) existing.c = colorCode; + else lightPixels.push(point); + } + + function removeLightPixel(x, y) { + lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); + } + + + function alignEditorStateToBottom(size) { + const shift = getBottomShift(editorPixels, size); + return { + rightPixels: shiftPixelsVertical(editorPixels, size, shift), + depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift), + lightPixels: lightPixels + .map((p) => ({ ...p, y: p.y + shift })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), + door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) } + }; + } + + function getBottomShift(pixels, size) { + let maxY = -1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (pixels[y * size + x]) maxY = Math.max(maxY, y); + } + } + return maxY < 0 ? 0 : size - 1 - maxY; + } + + function shiftPixelsVertical(pixels, size, shift) { + if (!shift) return normalizePixels(pixels, size); + const out = blankPixels(size); + const source = normalizePixels(pixels, size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const ny = y + shift; + if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || null; + } + } + return out; + } + + function shiftDepthPixelsVertical(pixels, size, shift) { + const source = normalizeDepthPixels(pixels, size); + if (!shift) return source; + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const ny = y + shift; + if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || 0; + } + } + return out; + } + + function saveAssetFromEditor() { + const role = currentRole(); + const category = roleToCategory(role); + const subtype = roleToSubtype(role); + const size = editorSize; + const existing = editingAssetId ? findAsset(editingAssetId) : null; + const paintedDots = countPixels(editorPixels); + if (paintedDots < 10) { + toast('Draw at least 10 pixels before saving.'); + return null; + } + const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`; + const aligned = alignEditorStateToBottom(size); + const asset = { + id: existing?.id || uid(), + name, + category, + subtype, + size, + createdAt: existing?.createdAt || Date.now(), + updatedAt: Date.now(), + author: existing?.author || state.authorName || 'Local Artist', + parentAssetId: existing ? existing.parentAssetId : editParentId, + originalAssetId: existing ? existing.originalAssetId : editOriginalId, + pixels: encodePixels(aligned.rightPixels), + faces: category === 'dynamic' ? { + right: encodePixels(aligned.rightPixels), + left: 'mirror' + } : null, + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size) + }; + asset.contentHash = computeAssetContentHash(asset); + if (existing) { + const index = state.assets.findIndex((a) => a.id === existing.id); + if (index >= 0) state.assets[index] = asset; + toast(`${asset.name} updated.`); + } else { + state.assets.unshift(asset); + toast(`${asset.name} saved.`); + } + selectedAssetId = asset.id; + recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); + editParentId = null; + editOriginalId = null; + editingAssetId = null; + editingAssetId = asset.id; + saveState(); + spriteCache.clear(); + hydrateRuntime(); + renderLibrary(); + updateSelectedLabel(); + els.lineageNote.textContent = existing ? 'Updated the original asset.' : 'Saved as a new asset.'; + return asset; + } + + function saveAndPlaceFromEditor() { + const asset = saveAssetFromEditor(); + if (!asset) return; + selectedAssetId = asset.id; + setMode('place'); + setDrawerOpen(false); + toast('Saved. Click an island tile to summon it.'); + } + + function newAsset() { + editParentId = null; + editOriginalId = null; + els.assetName.value = ''; + els.assetCategory.value = 'nature'; + staticKind = 'nature'; + dynamicKind = 'human'; + editingSide = 'right'; + lightPixels = []; + depthPixels = blankPixels(8).map(() => 0); + doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; + setupEditor(8, blankPixels(8), null); + clearEditorHistory(); + refreshCategoryUI(); + els.lineageNote.textContent = ''; + } + + + function hydrateAuthorUI() { + state.authorName = state.authorName || 'Local Artist'; + if (els.authorName) els.authorName.value = state.authorName; + } + + function focusAssetInWorld(asset) { + const placed = state.placed.find((p) => p.assetId === asset.id); + const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id); + const target = placed ? { x: placed.x + .5, y: placed.y + .5 } : dyn ? { x: dyn.homeX + .5, y: dyn.homeY + .5 } : null; + if (!target) { + selectedObject = null; + setMode('place'); + toast('Selected. Place it on the map.'); + return; + } + const pos = tileToWorld(target.x, target.y); + pos.y -= getLiftAtCoord(target.x, target.y); + view.x = cw / 2 - pos.x * view.zoom; + view.y = ch / 2 - pos.y * view.zoom; + if (placed) selectWorldObject('static', placed.id, asset.id, performance.now()); + else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now()); + setMode('inspect'); + } + + function renderLibrary() { + els.assetList.innerHTML = ''; + const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]); + if (!visibleAssets.length) { + els.assetList.textContent = 'No visible assets.'; + renderHiddenAssets(); + return; + } + const myName = (state.authorName || 'Local Artist').trim() || 'Local Artist'; + const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter; + const mine = visibleAssets.filter((asset) => (asset.author || 'Local Artist') === myName && matchesFilter(asset)); + const others = visibleAssets.filter((asset) => (asset.author || 'Local Artist') !== myName && matchesFilter(asset)); + + addLibrarySection('My works', mine); + addLibrarySection('Others', others); + renderHiddenAssets(); + } + + function addLibrarySection(title, assets) { + const heading = document.createElement('button'); + heading.type = 'button'; + heading.className = 'librarySectionTitle'; + heading.textContent = `${title} · ${libraryFilterLabel()}`; + heading.title = 'Click to filter by genre'; + heading.addEventListener('click', () => { + cycleLibraryFilter(); + renderLibrary(); + }); + els.assetList.append(heading); + if (!assets.length) { + const empty = document.createElement('div'); + empty.className = 'libraryEmptyNote'; + empty.textContent = 'No assets in this genre.'; + els.assetList.append(empty); + return; + } + const grid = document.createElement('div'); + grid.className = 'assetSectionGrid'; + for (const asset of assets) grid.append(makeAssetCard(asset)); + els.assetList.append(grid); + } + + function libraryFilterLabel() { + return libraryFilter === 'all' ? 'All' : cap(libraryFilter); + } + + function cycleLibraryFilter() { + const filters = ['all', 'human', 'animal', 'nature', 'building', 'other']; + const index = filters.indexOf(libraryFilter); + libraryFilter = filters[(index + 1) % filters.length]; + toast(`Library filter: ${libraryFilterLabel()}`); + } + + function makeAssetCard(asset) { + const card = document.createElement('article'); + card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`; + + const preview = document.createElement('canvas'); + preview.className = 'assetPreview'; + preview.width = 64; + preview.height = 64; + drawPreview(preview, asset); + + const meta = document.createElement('div'); + meta.className = 'assetMeta'; + const lineage = asset.parentAssetId ? ' / derivative' : ''; + meta.innerHTML = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`; + meta.querySelector('strong').textContent = asset.name; + meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`; + + card.addEventListener('click', () => { + selectedAssetId = asset.id; + updateSelectedLabel(); + renderLibrary(); + focusAssetInWorld(asset); + }); + + const actions = document.createElement('div'); + actions.className = 'assetActions'; + const assetVotes = getAssetVoteCounts(asset.id); + const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0; + const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); + const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); }); + up.classList.toggle('activeVote', assetPreviousVote > 0); + down.classList.toggle('activeVote', assetPreviousVote < 0); + up.classList.toggle('mutedVote', assetPreviousVote < 0); + down.classList.toggle('mutedVote', assetPreviousVote > 0); + const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); + hide.classList.toggle('mutedAction', assetPreviousVote >= 0); + const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => { + event?.stopPropagation?.(); + copyEdit(asset); + }); + const del = makeButton('Delete', (event) => { + event?.stopPropagation?.(); + deleteAsset(asset); + }); + del.classList.add('danger'); + actions.append(up, down, hide, copy, del); + meta.append(actions); + card.append(preview, meta); + return card; + } + + function renderHiddenAssets() { + if (!els.hiddenAssetList) return; + els.hiddenAssetList.innerHTML = ''; + const hidden = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]); + if (!hidden.length) { + els.hiddenAssetList.textContent = 'No hidden assets.'; + return; + } + for (const asset of hidden) { + const row = document.createElement('article'); + row.className = 'assetCard hiddenAssetCard'; + const preview = document.createElement('canvas'); + preview.className = 'assetPreview'; + preview.width = 64; + preview.height = 64; + drawPreview(preview, asset); + const meta = document.createElement('div'); + meta.className = 'assetMeta'; + meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`; + meta.querySelector('strong').textContent = asset.name; + const actions = document.createElement('div'); + actions.className = 'assetActions'; + const restore = makeButton('Show again', (event) => { + event.stopPropagation(); + delete state.hiddenAssets[asset.id]; + saveState(); + renderLibrary(); + toast('Asset shown again.'); + }); + actions.append(restore); + meta.append(actions); + row.append(preview, meta); + els.hiddenAssetList.append(row); + } + } + + function drawPreview(canvas, asset) { + const c = canvas.getContext('2d'); + c.imageSmoothingEnabled = false; + c.clearRect(0, 0, canvas.width, canvas.height); + c.fillStyle = '#fff3d9'; + c.fillRect(0, 0, canvas.width, canvas.height); + const pixels = getAssetPixels(asset, 'right'); + const scale = Math.floor(48 / asset.size) || 1; + const ox = Math.floor((canvas.width - asset.size * scale) / 2); + const oy = Math.floor((canvas.height - asset.size * scale) / 2); + for (let y = 0; y < asset.size; y++) { + for (let x = 0; x < asset.size; x++) { + const color = pixels[y * asset.size + x]; + if (!color) continue; + c.fillStyle = colorToHex(color); + c.fillRect(ox + x * scale, oy + y * scale, scale, scale); + } + } + } + + function renderPalette() { + if (!els.paletteGrid) return; + els.paletteGrid.innerHTML = ''; + for (const entry of PALETTE) { + const swatch = document.createElement('button'); + swatch.type = 'button'; + swatch.className = `paletteSwatch${entry.code === selectedColorCode ? ' active' : ''}`; + swatch.dataset.code = entry.code; + swatch.title = `${entry.code} ${entry.color}`; + swatch.style.background = entry.color; + swatch.addEventListener('click', () => { + selectPaletteCode(entry.code); + drawEditor(); + }); + els.paletteGrid.append(swatch); + } + els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b'; + } + + function displayCategory(asset) { + return asset.category === 'dynamic' ? 'People & Animals' : 'Buildings & Nature'; + } + + function makeButton(label, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.addEventListener('click', onClick); + return button; + } + + function copyEdit(asset) { + setDrawerOpen(true); + setTab('draw'); + const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + editingAssetId = mine ? asset.id : null; + editParentId = mine ? asset.parentAssetId : asset.id; + editOriginalId = mine ? asset.originalAssetId : (asset.originalAssetId || asset.id); + els.assetName.value = mine ? asset.name : `${asset.name} Remix`; + els.assetCategory.value = subtypeToRole(asset); + staticKind = asset.category === 'static' ? asset.subtype : staticKind; + dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; + editingSide = 'right'; + const right = getAssetPixels(asset, 'right'); + lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); + depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size); + doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 }; + setupEditor(asset.size, right, null, depthPixels); + clearEditorHistory(); + refreshCategoryUI(); + els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`; + } + + function deleteAsset(asset) { + const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); + if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return; + state.assets = state.assets.filter((a) => a.id !== asset.id); + state.placed = state.placed.filter((p) => p.assetId !== asset.id); + state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id); + if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null; + saveState(); + spriteCache.clear(); + hydrateRuntime(); + renderLibrary(); + updateSelectedLabel(); + } + + function updateSelectedLabel() { + if (!els.selectedAssetName) return; + const asset = findAsset(selectedAssetId); + els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none'; + } + + function hydrateRuntime() { + dynamicRuntime = state.dynamicSummons.map((summon) => { + const asset = findAsset(summon.assetId); + if (!asset) return null; + const homeTile = world.get(Math.round(summon.homeX), Math.round(summon.homeY)); + const homeValid = homeTile && ((asset.subtype === 'fish') ? homeTile.type === 'water' : homeTile.type !== 'water'); + const spawn = homeValid ? { x: Math.round(summon.homeX), y: Math.round(summon.homeY) } : findValidSpawn(asset, summon.homeX, summon.homeY, 6); + return { + id: summon.id, + assetId: summon.assetId, + homeX: summon.homeX, + homeY: summon.homeY, + x: spawn.x + .5, + y: spawn.y + .5, + targetX: spawn.x + .5, + targetY: spawn.y + .5, + vx: 1, + hiddenUntil: 0, + seed: Math.random() * 9999, + nextDecisionAt: 0, + nextBubbleAt: 800 + Math.random() * 1500 + }; + }).filter(Boolean); + } + + function findValidSpawn(asset, homeX, homeY, radius) { + if (asset.subtype === 'bird') { + const x = clamp(Math.round(homeX), 0, WORLD_W - 1); + const y = clamp(Math.round(homeY), 0, WORLD_H - 1); + if (world.get(x, y)?.type !== 'water') return { x, y }; + } + const candidates = []; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = Math.round(homeX + dx); + const y = Math.round(homeY + dy); + if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) continue; + const tile = world.get(x, y); + if (asset.subtype === 'fish') { + if (tile.type === 'water') candidates.push({ x, y }); + } else if (tile.type !== 'water') candidates.push({ x, y }); + } + } + if (!candidates.length) return { x: clamp(Math.round(homeX), 0, WORLD_W - 1), y: clamp(Math.round(homeY), 0, WORLD_H - 1) }; + return candidates[Math.floor(Math.random() * candidates.length)]; + } + + function updateDynamicRuntime(dt, time) { + bubbleParticles = bubbleParticles.filter((p) => time - p.started < p.life); + confettiParticles = confettiParticles.filter((p) => time - p.started < p.life); + for (const item of dynamicRuntime) { + const asset = findAsset(item.assetId); + if (!asset) continue; + if (time < item.hiddenUntil) continue; + if (asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) { + spawnFishBubbleCluster(item.x, item.y, time, item.seed); + item.nextBubbleAt = time + 1100 + Math.random() * 1800; + } + const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); + if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); + + const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; + const dx = item.targetX - item.x; + const dy = item.targetY - item.y; + const len = Math.hypot(dx, dy) || 1; + item.vx = dx === 0 ? item.vx : Math.sign(dx); + item.x += (dx / len) * speed; + item.y += (dy / len) * speed; + + if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) { + item.hiddenUntil = time + 1700 + Math.random() * 2200; + } + } + } + + function chooseTarget(item, asset, time) { + let target = null; + if (asset.subtype === 'human') target = findNearestPlaced(item.x, item.y, 'building', 14); + else if (asset.subtype === 'animal' || asset.subtype === 'bird') target = findNearestPlaced(item.x, item.y, 'nature', 16); + + if (target && Math.random() < .72) { + item.targetX = target.x + .5 + (Math.random() - .5) * 1.8; + item.targetY = target.y + .5 + (Math.random() - .5) * 1.8; + } else { + const spawn = findValidSpawn(asset, item.homeX, item.homeY, asset.subtype === 'bird' ? 2 : 7); + item.targetX = spawn.x + .5; + item.targetY = spawn.y + .5; + } + item.nextDecisionAt = time + 1600 + Math.random() * 2800; + } + + function findNearestPlaced(x, y, subtype, maxDistance) { + let best = null; + let bestDistance = maxDistance; + const minX = clampInt(Math.floor(x - maxDistance), 0, WORLD_W - 1, 0); + const maxX = clampInt(Math.ceil(x + maxDistance), 0, WORLD_W - 1, WORLD_W - 1); + const minY = clampInt(Math.floor(y - maxDistance), 0, WORLD_H - 1, 0); + const maxY = clampInt(Math.ceil(y + maxDistance), 0, WORLD_H - 1, WORLD_H - 1); + for (let ty = minY; ty <= maxY; ty++) { + for (let tx = minX; tx <= maxX; tx++) { + const bucket = worldIndex.placedByTile.get(tileKey(tx, ty)); + if (!bucket) continue; + for (const placed of bucket) { + const asset = findAsset(placed.assetId); + if (!asset || asset.subtype !== subtype) continue; + const distance = Math.hypot(placed.x + .5 - x, placed.y + .5 - y); + if (distance < bestDistance) { + bestDistance = distance; + best = placed; + } + } + } + } + return best; + } + + + function scheduleFrame() { + if (animationFrameId || document.hidden) return; + animationFrameId = requestAnimationFrame(tick); + } + + function onVisibilityChange() { + if (document.hidden) { + if (animationFrameId) cancelAnimationFrame(animationFrameId); + animationFrameId = 0; + lastRuntimeUpdate = performance.now(); + dynamicLogicRemainder = 0; + return; + } + lastRuntimeUpdate = performance.now(); + dynamicLogicRemainder = 0; + render(lastRuntimeUpdate); + scheduleFrame(); + } + + function tick(time) { + animationFrameId = 0; + if (document.hidden) { + lastRuntimeUpdate = time; + return; + } + const elapsed = Math.min(100, Math.max(0, time - lastRuntimeUpdate)); + lastRuntimeUpdate = time; + dynamicLogicRemainder += elapsed; + let steps = 0; + while (dynamicLogicRemainder >= DYNAMIC_LOGIC_STEP_MS && steps < MAX_DYNAMIC_STEPS_PER_FRAME) { + updateDynamicRuntime(DYNAMIC_LOGIC_STEP_MS / 1000, time); + dynamicLogicRemainder -= DYNAMIC_LOGIC_STEP_MS; + steps++; + } + if (steps >= MAX_DYNAMIC_STEPS_PER_FRAME) dynamicLogicRemainder = 0; + updateClock(); + render(time); + scheduleFrame(); + } + + function updateClock() { + const phase = getPhase(); + const second = Math.floor(Date.now() / 1000); + if (second === lastClockSecond) return; + lastClockSecond = second; + els.phaseLabel.textContent = phase.label; + els.phaseBar.style.width = `${Math.round(phase.progress * 100)}%`; + } + + function getShadowForMinute(minute) { + const isNight = minute >= 6; + const local = isNight ? (minute - 6) / 4 : minute / 6; + const eased = clamp(local, 0, 1); + const elevation = Math.sin(eased * Math.PI); + // Morning: down-left, Noon: straight down, Evening: down-right. + // The top edge of the shadow is always anchored to the sprite's bottom edge. + const dirX = lerp(-1.0, 1.0, eased); + const length = lerp(1.35, 0.58, elevation); + return { + dirX, + length, + skewX: dirX * (0.72 + 0.32 * length), + scaleY: 0.24 + 0.06 * length, + alpha: isNight ? .12 : lerp(.28, .16, elevation) + }; + } + + function getPhase() { + const t = mod(Date.now(), DAY_MS); + const minute = t / 60000; + const stops = [ + { at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] }, + { at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, + { at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, + { at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] }, + { at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] } + ]; + let a = stops[0], b = stops[1]; + for (let i = 0; i < stops.length - 1; i++) { + if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; } + if (minute >= 6) { a = stops[3]; b = stops[4]; } + } + const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); + const eased = localT * localT * (3 - 2 * localT); + const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); + const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night'; + return { + key: label.toLowerCase(), + label, + progress: t / DAY_MS, + darkness: lerp(a.darkness, b.darkness, eased), + tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, + shadow: getShadowForMinute(minute) + }; + } + + function render(time = performance.now()) { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cw, ch); + ctx.fillStyle = '#86d5ff'; + ctx.fillRect(0, 0, cw, ch); + const phase = getPhase(); + renderPhase = phase; + + ctx.save(); + ctx.translate(view.x, view.y); + ctx.scale(view.zoom, view.zoom); + ctx.imageSmoothingEnabled = false; + drawTerrainCache(terrainCache); + drawHoverTile(); + const lightSources = []; + drawObjects(time, lightSources, phase); + drawBubbleParticles(time); + drawSpawnEffects(time); + drawConfettiParticles(time); + ctx.restore(); + + updateSelectionBubble(time); + if (phase.tint !== 'rgba(255, 255, 255, 0)') { + ctx.fillStyle = phase.tint; + ctx.fillRect(0, 0, cw, ch); + } + if (phase.darkness > 0) { + ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`; + ctx.fillRect(0, 0, cw, ch); + drawLightSources(lightSources, phase.darkness); + } + } + + function drawHoverTile() { + if (!hoverTile) return; + const { x, y } = tileToWorld(hoverTile.x, hoverTile.y); + const lift = getTileLift(hoverTile.tile); + ctx.beginPath(); + ctx.moveTo(x, y - lift); + ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); + ctx.lineTo(x, y + TILE_H - lift); + ctx.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift); + ctx.closePath(); + ctx.strokeStyle = 'rgba(255, 255, 255, .7)'; + ctx.lineWidth = 1 / view.zoom; + ctx.stroke(); + } + + function drawSpawnEffects(time) { + if (!spawnEffects.length) return; + spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650); + for (const effect of spawnEffects) { + const t = clamp((time - effect.started) / 650, 0, 1); + const pos = tileToWorld(effect.x, effect.y); + pos.y -= getLiftAtCoord(effect.x, effect.y); + const radius = 6 + t * 24; + ctx.save(); + ctx.globalAlpha = 1 - t; + ctx.lineWidth = Math.max(1, 3 / view.zoom); + ctx.strokeStyle = '#ff79ad'; + ctx.beginPath(); + ctx.arc(pos.x, pos.y + TILE_H * .35, radius, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = '#fff2a8'; + for (let i = 0; i < 5; i++) { + const a = i * Math.PI * .4 + t * 2.4; + ctx.fillRect(pos.x + Math.cos(a) * radius - 2, pos.y + TILE_H * .35 + Math.sin(a) * radius - 2, 4, 4); + } + ctx.restore(); + } + } + + function drawObjects(time, lightSources, phase) { + const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); + + for (const item of items) { + if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { + drawSpriteItem(item, time, lightSources, true, phase); + } + } + for (const item of items) { + if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) { + drawSpriteItem(item, time, lightSources, false, phase); + } + } + } + + function getSpriteDrawInfo(item, time, includeSelectBounce = true) { + const asset = item.asset; + const pos = tileToWorld(item.x, item.y); + const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird'); + if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y); + let bob = 0; + if (includeSelectBounce && asset.category === 'static' && selectedObject?.id === item.source?.id) { + const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 520, 0, 1); + if (selectedT < 1) bob += -Math.sin(selectedT * Math.PI) * 8; + } + let alpha = 1; + let side = 'right'; + let angle = 0; + if (asset.category === 'static' && asset.subtype === 'water') bob += Math.sin(time / 900 + item.x * .7) * 1.6; + if (asset.category === 'dynamic') { + const runtime = item.source; + side = runtime.vx < 0 ? 'left' : 'right'; + if (asset.subtype === 'human' || asset.subtype === 'animal') { + const jumpPhase = time / 78 + runtime.seed * 0.9; + const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); + const liftNow = getLiftAtCoord(item.x, item.y); + const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); + const terrainBoost = Math.abs(liftTarget - liftNow) / 12; + bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); + const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); + const rand = pseudoNoise(cycle + runtime.seed * 13.17); + angle = (rand - 0.5) * 0.24 * hop; + } + if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5; + if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; } + } + const sprite = getSpriteCanvas(asset, side); + const drawX = pos.x - sprite.width / 2; + const drawY = pos.y + TILE_H / 2 - sprite.height + bob; + return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob }; + } + + function drawSpriteItem(item, time, lightSources, underwater, phase) { + const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); + + if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); + + ctx.save(); + ctx.globalAlpha = alpha; + if (angle) { + ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8)); + ctx.rotate(angle); + ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); + } else { + ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY)); + } + if (underwater) { + ctx.fillStyle = 'rgba(103, 181, 217, .18)'; + if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height); + else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height); + } + ctx.restore(); + + if (asset.meta?.hasLight && asset.meta.lightPixels?.length) { + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + for (const light of asset.meta.lightPixels) { + const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x; + const wx = drawX + (lx + 0.5) * scale; + const wy = drawY + (light.y + 0.5) * scale; + lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(12, 20, asset.size / 64) }); + } + } + } + + function drawFishBubbles(x, y, time, seed) { + const cycle = Math.floor((time + seed * 37) / 1300); + if (pseudoNoise(cycle + seed) < 0.55) return; + const t = ((time + seed * 37) % 1300) / 1300; + ctx.save(); + ctx.fillStyle = 'rgba(225, 252, 255, .58)'; + ctx.globalAlpha = Math.max(0, 1 - t); + for (let i = 0; i < 4; i++) { + const ox = Math.round((pseudoNoise(cycle + seed + i * 4.1) - .5) * 7); + const oy = Math.round(-t * 12 - i * 3); + ctx.fillRect(Math.round(x + ox), Math.round(y + oy), 1, 1); + } + ctx.restore(); + } + + + + function drawSpriteShadow(drawX, drawY, sprite, phase) { + const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 }; + const silhouette = getShadowCanvas(sprite); + const contactX = drawX + sprite.width / 2; + const contactY = drawY + sprite.height; + + ctx.save(); + ctx.globalAlpha = shadow.alpha; + // The silhouette is vertically flipped in getShadowCanvas(). + // Drawing it at y=0 means the flipped top edge, which was the sprite's bottom edge, + // exactly touches the sprite's bottom/contact line. + ctx.translate(contactX, contactY); + ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); + ctx.drawImage(silhouette, Math.round(-sprite.width / 2), 0); + ctx.restore(); + } + + function getShadowCanvas(sprite) { + if (shadowCanvasCache.has(sprite)) return shadowCanvasCache.get(sprite); + const canvas = document.createElement('canvas'); + canvas.width = sprite.width; + canvas.height = sprite.height; + const c = canvas.getContext('2d'); + c.imageSmoothingEnabled = false; + c.save(); + c.translate(0, sprite.height); + c.scale(1, -1); + c.drawImage(sprite, 0, 0); + c.restore(); + c.globalCompositeOperation = 'source-in'; + c.fillStyle = '#1b1f26'; + c.fillRect(0, 0, canvas.width, canvas.height); + shadowCanvasCache.set(sprite, canvas); + return canvas; + } + + + function currentVoterKey() { + return ((state.authorName || els.authorName?.value || 'Local Artist').trim() || 'Local Artist').toLowerCase(); + } + + function getObjectVoteCounts(objectId) { + state.objectVotes ||= {}; + const votes = state.objectVotes[objectId] || { up: 0, down: 0, voters: {} }; + votes.voters ||= {}; + return votes; + } + + function getAssetVoteCounts(assetId) { + state.assetVotes ||= {}; + const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} }; + votes.voters ||= {}; + return votes; + } + + function voteAsset(assetId, delta) { + state.assetVotes ||= {}; + const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} }; + votes.voters ||= {}; + const voter = currentVoterKey(); + const previous = votes.voters[voter] || 0; + if (previous > 0) votes.up = Math.max(0, votes.up - 1); + if (previous < 0) votes.down = Math.max(0, votes.down - 1); + if (previous === delta) delete votes.voters[voter]; + else { + if (delta > 0) votes.up += 1; + else votes.down += 1; + votes.voters[voter] = delta; + } + state.assetVotes[assetId] = votes; + saveState(); + renderLibrary(); + } + + function hideAsset(assetId) { + state.hiddenAssets ||= {}; + state.hiddenAssets[assetId] = true; + if (selectedAssetId === assetId) selectedAssetId = state.assets.find((a) => !state.hiddenAssets?.[a.id])?.id || null; + if (selectedObject?.assetId === assetId) selectedObject = null; + saveState(); + renderLibrary(); + updateSelectionBubble(performance.now()); + toast('Asset hidden locally.'); + } + + function getRemixCount(assetId) { + return state.assets.filter((asset) => asset.parentAssetId === assetId || asset.originalAssetId === assetId).length; + } + + function countPixels(pixels) { + return Array.isArray(pixels) ? pixels.filter(Boolean).length : normalizePixels(pixels || [], editorSize).filter(Boolean).length; + } + + function getSelectedWorldPosition() { + if (!selectedObject) return null; + const asset = findAsset(selectedObject.assetId); + if (!asset) return null; + if (selectedObject.kind === 'static') { + const placed = state.placed.find((p) => p.id === selectedObject.id); + if (!placed) return null; + const pos = tileToWorld(placed.x + .5, placed.y + .5); + pos.y -= getLiftAtCoord(placed.x + .5, placed.y + .5); + return { pos, asset, objectId: placed.id }; + } + const runtime = dynamicRuntime.find((r) => r.id === selectedObject.id); + const summon = state.dynamicSummons.find((s) => s.id === selectedObject.id); + const source = runtime || (summon ? { x: summon.homeX + .5, y: summon.homeY + .5 } : null); + if (!source) return null; + const pos = tileToWorld(source.x, source.y); + if (asset.subtype !== 'bird') pos.y -= getLiftAtCoord(source.x, source.y); + return { pos, asset, objectId: selectedObject.id }; + } + + function updateSelectionBubble(time) { + if (!els.selectionBubble) return; + const selected = getSelectedWorldPosition(); + if (!selected) { + els.selectionBubble.hidden = true; + return; + } + const { pos, asset, objectId } = selected; + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const topOffset = Math.max(18, asset.size * scale + 26); + const sx = pos.x * view.zoom + view.x; + const sy = (pos.y - topOffset) * view.zoom + view.y; + if (sx < -100 || sy < -100 || sx > cw + 100 || sy > ch + 100) { + els.selectionBubble.hidden = true; + return; + } + els.selectionBubble.hidden = false; + els.selectionBubble.style.transform = `translate(${Math.round(sx)}px, ${Math.round(sy)}px) translate(-50%, -100%)`; + els.bubbleName.textContent = asset.name || 'Untitled'; + els.bubbleAuthor.textContent = `by ${asset.author || 'Local Artist'}`; + const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null; + if (els.bubbleRemixFrom) { + els.bubbleRemixFrom.hidden = !parent; + els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : ''; + } + if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`; + const votes = getObjectVoteCounts(objectId); + const voter = currentVoterKey(); + const previous = votes.voters?.[voter] || 0; + els.voteScore.textContent = `${votes.up - votes.down}`; + els.voteUp.classList.toggle('activeVote', previous > 0); + els.voteDown.classList.toggle('activeVote', previous < 0); + els.voteUp.classList.toggle('mutedVote', previous < 0); + els.voteDown.classList.toggle('mutedVote', previous > 0); + if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0; + } + + function voteSelected(delta) { + if (!selectedObject) return; + state.objectVotes ||= {}; + const key = selectedObject.id; + const votes = state.objectVotes[key] || { up: 0, down: 0, voters: {} }; + votes.voters ||= {}; + const voter = currentVoterKey(); + const previous = votes.voters[voter] || 0; + if (previous > 0) votes.up = Math.max(0, votes.up - 1); + if (previous < 0) votes.down = Math.max(0, votes.down - 1); + if (previous === delta) { + delete votes.voters[voter]; + } else { + if (delta > 0) { + votes.up += 1; + spawnConfettiAtSelection(performance.now()); + } else { + votes.down += 1; + } + votes.voters[voter] = delta; + } + state.objectVotes[key] = votes; + saveState(); + updateSelectionBubble(performance.now()); + renderLibrary(); + // Server moderation TODO: when online, hide or delete objects from the server if downvotes exceed a threshold + // such as down >= 5 and down - up >= 3. Do not implement server deletion in this local prototype. + } + + function remixSelected() { + if (!selectedObject) return; + const asset = findAsset(selectedObject.assetId); + if (asset) copyEdit(asset); + } + + function hideSelected() { + if (!selectedObject) return; + state.hiddenObjects ||= {}; + state.hiddenObjects[selectedObject.id] = true; + const assetId = selectedObject.assetId; + selectedObject = null; + saveState(); + updateSelectionBubble(performance.now()); + renderLibrary(); + toast('Hidden locally.'); + // Server moderation TODO: when synced, repeated low-rating + hide actions can flag this object for review. + } + + function spawnFishBubbleCluster(tileX, tileY, time, seed = 0) { + const count = 2 + Math.floor(pseudoNoise(time * 0.003 + seed) * 3); + for (let i = 0; i < count; i++) { + bubbleParticles.push({ + x: tileX + (pseudoNoise(seed + time * .01 + i * 7.1) - .5) * .22, + y: tileY + (pseudoNoise(seed + time * .013 + i * 5.7) - .5) * .22, + started: time + i * 45, + life: 900 + pseudoNoise(seed + i * 3.3) * 550, + drift: (pseudoNoise(seed + i * 9.9) - .5) * .08 + }); + } + } + + function drawBubbleParticles(time) { + if (!bubbleParticles.length) return; + ctx.save(); + ctx.fillStyle = 'rgba(225, 252, 255, .62)'; + for (const particle of bubbleParticles) { + const age = (time - particle.started) / particle.life; + if (age < 0 || age > 1) continue; + const pos = tileToWorld(particle.x + particle.drift * age, particle.y); + pos.y -= 3 + age * 18; + ctx.globalAlpha = 1 - age; + ctx.fillRect(Math.round(pos.x), Math.round(pos.y), 1, 1); + } + ctx.restore(); + } + + function spawnConfettiAtSelection(time) { + const selected = getSelectedWorldPosition(); + if (!selected) return; + const palette = ['a', 'f', 'l', 's', 'A', 'H', 'P', 'Z']; + for (let i = 0; i < 22; i++) { + const angle = (Math.PI * 2 * i) / 22 + pseudoNoise(time + i) * .4; + const speed = 18 + pseudoNoise(i * 4.7 + time) * 34; + confettiParticles.push({ + x: selected.pos.x, + y: selected.pos.y - selected.asset.size * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 25, + color: palette[i % palette.length], + started: time, + life: 850 + pseudoNoise(i + time) * 450 + }); + } + } + + function drawConfettiParticles(time) { + if (!confettiParticles.length) return; + ctx.save(); + for (const particle of confettiParticles) { + const ageMs = time - particle.started; + const t = ageMs / particle.life; + if (t < 0 || t > 1) continue; + const seconds = ageMs / 1000; + const x = particle.x + particle.vx * seconds; + const y = particle.y + particle.vy * seconds + 48 * seconds * seconds; + ctx.globalAlpha = 1 - t; + ctx.fillStyle = colorToHex(particle.color); + ctx.fillRect(Math.round(x), Math.round(y), 3, 3); + } + ctx.restore(); + } + + function drawLightSources(sources, darkness) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const source of sources) { + const sx = source.x * view.zoom + view.x; + const sy = source.y * view.zoom + view.y; + const radius = source.radius * view.zoom; + if (sx < -radius || sy < -radius || sx > cw + radius || sy > ch + radius) continue; + const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius); + gradient.addColorStop(0, hexToRgba(source.color, .18 + darkness * .10)); + gradient.addColorStop(.35, hexToRgba(source.color, .08)); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(sx, sy, radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + function getSpriteCanvas(asset, side) { + const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}`; + if (spriteCache.has(key)) return spriteCache.get(key); + const pixels = getAssetPixels(asset, side); + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const canvas = document.createElement('canvas'); + canvas.width = asset.size * scale; + canvas.height = asset.size * scale; + const c = canvas.getContext('2d'); + c.imageSmoothingEnabled = false; + for (let y = 0; y < asset.size; y++) { + for (let x = 0; x < asset.size; x++) { + const color = pixels[y * asset.size + x]; + if (!color) continue; + c.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y, side)); + c.fillRect(x * scale, y * scale, scale, scale); + } + } + spriteCache.set(key, canvas); + return canvas; + } + + function getAssetPixels(asset, side = 'right') { + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size); + if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size); + return right; + } + + function makeWorld() { + const islands = [ + { cx: 36, cy: 34, rx: 32, ry: 22, height: 1.04 }, + { cx: 92, cy: 30, rx: 34, ry: 20, height: .98 }, + { cx: 74, cy: 76, rx: 42, ry: 24, height: 1.02 }, + { cx: 124, cy: 72, rx: 16, ry: 14, height: .92 }, + { cx: 26, cy: 86, rx: 16, ry: 12, height: .9 }, + { cx: 118, cy: 102, rx: 20, ry: 9, height: .86 } + ]; + const tiles = []; + for (let y = 0; y < WORLD_H; y++) { + for (let x = 0; x < WORLD_W; x++) { + const continent = fractalPerlin(x * .035, y * .035, 4); + const detail = fractalPerlin(x * .095 + 41.7, y * .095 - 13.2, 3); + let score = -1.2; + for (const island of islands) { + const warpX = (fractalPerlin(x * .055 + island.cx, y * .055, 2) - .5) * 7.0; + const warpY = (fractalPerlin(x * .055, y * .055 + island.cy, 2) - .5) * 5.0; + const dx = (x + warpX - island.cx) / island.rx; + const dy = (y + warpY - island.cy) / island.ry; + const base = 1 - (dx * dx + dy * dy); + score = Math.max(score, base * island.height); + } + score += (continent - .5) * .36 + (detail - .5) * .18; + let type = 'water'; + if (score > .015) type = 'sand'; + if (score > .17) type = 'grass'; + if (score > .56 && fractalPerlin(x * .16 + 8, y * .16 + 4, 2) > .56) type = 'highland'; + const heightLevel = type === 'highland' ? 1 : 0; + tiles.push({ x, y, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 }); + } + } + return { + tiles, + get(x, y) { + if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) return null; + return tiles[y * WORLD_W + x]; + } + }; + } + + function buildTerrainCache(worldData) { + const chunkSize = TERRAIN_CHUNK_SIZE; + const chunks = []; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let cy = 0; cy < WORLD_H; cy += chunkSize) { + for (let cx = 0; cx < WORLD_W; cx += chunkSize) { + const tiles = []; + for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) { + for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) { + const tile = worldData.get(x, y); + if (tile) tiles.push(tile); + } + } + if (!tiles.length) continue; + const bounds = getTerrainChunkBounds(tiles); + const canvas = document.createElement('canvas'); + canvas.width = Math.ceil(bounds.w); + canvas.height = Math.ceil(bounds.h); + const c = canvas.getContext('2d', { alpha: true }); + c.imageSmoothingEnabled = false; + c.save(); + c.translate(-bounds.x, -bounds.y); + for (const tile of tiles) drawTerrainTile(c, tile, worldData); + c.restore(); + chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy }); + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + canvas.width); + maxY = Math.max(maxY, bounds.y + canvas.height); + } + } + if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize }; + return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize }; + } + + function getTerrainChunkBounds(tiles) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const tile of tiles) { + const p = tileToWorld(tile.x, tile.y); + const lift = getTileLift(tile); + minX = Math.min(minX, p.x - TILE_W / 2 - 3); + maxX = Math.max(maxX, p.x + TILE_W / 2 + 3); + minY = Math.min(minY, p.y - lift - 16); + maxY = Math.max(maxY, p.y + TILE_H + 18); + } + minX = Math.floor(minX); + minY = Math.floor(minY); + maxX = Math.ceil(maxX); + maxY = Math.ceil(maxY); + return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) }; + } + + function drawTerrainCache(cache) { + const rect = getViewportWorldRect(64); + for (const chunk of cache.chunks) { + if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue; + ctx.drawImage(chunk.canvas, chunk.x, chunk.y); + } + } + + + function drawTerrainTile(c, tile, worldData) { + const { x, y } = tileToWorld(tile.x, tile.y); + const palette = { + water: ['#6ebfe0', '#69bbdc'], + sand: ['#ead99d', '#e8d293'], + grass: ['#91cf76', '#89c970'], + highland: ['#9db781', '#95ab79'] + }; + const [base, alt] = palette[tile.type]; + const fill = tile.shade > 0 ? base : alt; + const lift = getTileLift(tile); + + if (tile.type === 'highland') { + const frontLeft = worldData.get(tile.x, tile.y + 1); + const frontRight = worldData.get(tile.x + 1, tile.y); + const leftLower = !frontLeft || frontLeft.type !== 'highland'; + const rightLower = !frontRight || frontRight.type !== 'highland'; + if (rightLower) { + c.fillStyle = '#7b8e6c'; + c.beginPath(); + c.moveTo(x, y + TILE_H - lift); + c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); + c.lineTo(x + TILE_W / 2, y + TILE_H / 2); + c.lineTo(x, y + TILE_H); + c.closePath(); + c.fill(); + } + if (leftLower) { + c.fillStyle = '#6f8263'; + c.beginPath(); + c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift); + c.lineTo(x, y + TILE_H - lift); + c.lineTo(x, y + TILE_H); + c.lineTo(x - TILE_W / 2, y + TILE_H / 2); + c.closePath(); + c.fill(); + } + } + + c.fillStyle = fill; + c.beginPath(); + c.moveTo(x, y - lift); + c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); + c.lineTo(x, y + TILE_H - lift); + c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift); + c.closePath(); + c.fill(); + + if (tile.type === 'water') { + c.fillStyle = 'rgba(255,255,255,.045)'; + c.beginPath(); + c.moveTo(x - TILE_W * .20, y + TILE_H * .48); + c.lineTo(x + TILE_W * .15, y + TILE_H * .31); + c.lineTo(x + TILE_W * .25, y + TILE_H * .38); + c.lineTo(x - TILE_W * .10, y + TILE_H * .55); + c.closePath(); + c.fill(); + } + + c.strokeStyle = 'rgba(36, 48, 68, .02)'; + c.lineWidth = 1; + c.beginPath(); + c.moveTo(x, y - lift); + c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); + c.lineTo(x, y + TILE_H - lift); + c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift); + c.closePath(); + c.stroke(); + } + + function getTileLift(tile) { + return tile?.type === 'highland' ? 12 : 0; + } + + function getLiftAtCoord(x, y) { + const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1)); + return getTileLift(tile); + } + + function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + const expanded = Phase2Sync?.isCompactState?.(parsed) ? Phase2Sync.expandState(parsed) : parsed; + if (expanded && Array.isArray(expanded.assets)) return normalizeState(expanded); + } + } catch (error) { + console.warn('Could not load local state.', error); + } + return seedState(); + } + + function saveState() { + state.schema = SAVE_SCHEMA; + state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : []; + const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + cachePhase2State(); + updateSyncStats(); + } + + function normalizeState(input) { + return { + schema: SAVE_SCHEMA, + authorName: input.authorName || 'Local Artist', + assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [], + placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []), + dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []), + objectVotes: input.objectVotes || {}, + assetVotes: input.assetVotes || {}, + hiddenAssets: input.hiddenAssets || {}, + hiddenObjects: input.hiddenObjects || {}, + eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], + sync: input.sync || { lastEventId: null } + }; + } + + function normalizeAsset(asset) { + const size = Number(asset.size) || 16; + const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size); + const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other'); + const normalized = { + id: asset.id || uid(), + name: asset.name || 'Untitled', + category, + subtype, + size, + pixels: encodePixels(right), + faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null, + parentAssetId: asset.parentAssetId || null, + originalAssetId: asset.originalAssetId || null, + createdAt: asset.createdAt || Date.now(), + updatedAt: asset.updatedAt || asset.createdAt || Date.now(), + author: asset.author || 'Local Artist', + meta: normalizeAssetMeta(asset.meta || {}, category, subtype, size) + }; + normalized.contentHash = computeAssetContentHash(normalized); + return normalized; + } + + function normalizeAssetMeta(meta, category, subtype, size) { + const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size); + const lightPixels = Array.isArray(meta.lightPixels) + ? meta.lightPixels + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + : []; + return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size); + } + + function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize) { + const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); + const hasDepth = normalizedDepth.some(Boolean); + const lightPixelsClean = Array.isArray(sourceLightPixels) + ? sourceLightPixels + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) + : []; + const hasLight = lightPixelsClean.length > 0; + return { + hasLight, + lightPixels: hasLight ? lightPixelsClean : [], + lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, + depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null, + door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null + }; + } + + function normalizePlacements(items) { + return dedupeByAsset(items).map((item) => ({ + id: item.id || uid(), + assetId: item.assetId, + x: clampInt(item.x, 0, WORLD_W - 1, 0), + y: clampInt(item.y, 0, WORLD_H - 1, 0), + placedAt: item.placedAt || item.createdAt || Date.now(), + version: Number(item.version) || 1 + })).filter((item) => item.assetId); + } + + function normalizeDynamicSummons(items) { + return dedupeByAsset(items).map((item) => ({ + id: item.id || uid(), + assetId: item.assetId, + homeX: clampInt(item.homeX ?? item.x, 0, WORLD_W - 1, 0), + homeY: clampInt(item.homeY ?? item.y, 0, WORLD_H - 1, 0), + createdAt: item.createdAt || item.placedAt || Date.now(), + version: Number(item.version) || 1 + })).filter((item) => item.assetId); + } + + function dedupeByAsset(items) { + const map = new Map(); + for (const item of items) { + if (item && item.assetId) map.set(item.assetId, item); + } + return [...map.values()]; + } + + function findNearestTerrain(type, startX, startY) { + let best = null; + let bestD = Infinity; + const maxR = Math.max(WORLD_W, WORLD_H); + for (let r = 0; r < maxR; r++) { + for (let y = Math.max(0, startY - r); y <= Math.min(WORLD_H - 1, startY + r); y++) { + for (let x = Math.max(0, startX - r); x <= Math.min(WORLD_W - 1, startX + r); x++) { + const tile = world.get(x, y); + if (!tile || tile.type !== type) continue; + const d = Math.hypot(x - startX, y - startY); + if (d < bestD) { bestD = d; best = { x, y }; } + } + } + if (best) return best; + } + return { x: startX, y: startY }; + } + + function homeFromPos(pos) { + return { homeX: pos.x, homeY: pos.y }; + } + + function seedState() { + const assets = [ + makeAsset('Cozy House', 'static', 'building', drawHouse(), { hasLight: true, lightPixels: [{ x: 10, y: 8 }, { x: 11, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }), + makeAsset('Round Tree', 'static', 'nature', drawTree()), + makeAsset('Dock Lamp', 'static', 'water', drawLantern(), { hasLight: true, lightPixels: [{ x: 7, y: 6 }, { x: 8, y: 6 }], lightColor: '#b8e8ff' }), + makeAsset('Traveler', 'dynamic', 'human', drawHumanRight(), {}, drawHumanLeft()), + makeAsset('Island Pup', 'dynamic', 'animal', drawDogRight(), {}, drawDogLeft()), + makeAsset('Blue Fish', 'dynamic', 'fish', drawFishRight(), {}, drawFishLeft()), + makeAsset('Tiny Bird', 'dynamic', 'bird', drawBirdRight(), {}, drawBirdLeft()) + ]; + + const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id])); + return { + schema: SAVE_SCHEMA, + authorName: 'Local Artist', + assets, + placed: [ + { id: uid(), assetId: idByName['Cozy House'], x: 36, y: 36, placedAt: Date.now() }, + { id: uid(), assetId: idByName['Round Tree'], x: 32, y: 36, placedAt: Date.now() }, + { id: uid(), assetId: idByName['Dock Lamp'], ...findNearestTerrain('water', 62, 58), placedAt: Date.now() } + ], + objectVotes: {}, + assetVotes: {}, + hiddenAssets: {}, + hiddenObjects: {}, + dynamicSummons: [ + { id: uid(), assetId: idByName['Traveler'], homeX: 36, homeY: 38, createdAt: Date.now() }, + { id: uid(), assetId: idByName['Island Pup'], homeX: 32, homeY: 40, createdAt: Date.now() }, + { id: uid(), assetId: idByName['Blue Fish'], ...homeFromPos(findNearestTerrain('water', 63, 60)), createdAt: Date.now() }, + { id: uid(), assetId: idByName['Tiny Bird'], homeX: 90, homeY: 30, createdAt: Date.now() } + ] + }; + } + + function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) { + const size = 16; + const id = uid(); + const right = alignPixelsToBottom(normalizePixels(pixels, size), size); + const asset = { + id, + name, + category, + subtype, + size, + pixels: encodePixels(right), + faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null, + parentAssetId: null, + originalAssetId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + author: 'Island Team', + meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size) + }; + asset.contentHash = computeAssetContentHash(asset); + return asset; + } + + + function alignPixelsToBottom(pixels, size) { + const source = normalizePixels(pixels, size); + let maxY = -1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (source[y * size + x]) maxY = Math.max(maxY, y); + } + } + if (maxY < 0 || maxY === size - 1) return source; + const dy = size - 1 - maxY; + const out = blankPixels(size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const value = source[y * size + x]; + if (value && y + dy < size) out[(y + dy) * size + x] = value; + } + } + return out; + } + + function exportData() { + els.dataBox.value = JSON.stringify(state, null, 2); + toast('Full JSON exported.'); + } + + function exportCompactData() { + const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; + els.dataBox.value = JSON.stringify(payload); + toast('Compact JSON exported.'); + } + + function exportSnapshotData() { + if (!Phase2Sync?.makeSnapshot) { + exportData(); + return; + } + els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state)); + toast('Snapshot JSON exported.'); + } + + function exportAssetBundleData() { + if (!Phase2Sync?.makeAssetBundle) { + exportData(); + return; + } + els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id))); + toast('Asset bundle JSON exported.'); + } + + function importData() { + try { + const imported = JSON.parse(els.dataBox.value); + if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) { + toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.'); + return; + } + if (Phase2Sync?.isAssetBundle?.(imported)) { + const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset); + const byId = new Map(state.assets.map((asset) => [asset.id, asset])); + importedAssets.forEach((asset) => byId.set(asset.id, asset)); + state.assets = [...byId.values()]; + saveState(); + spriteCache.clear(); + renderLibrary(); + updateSelectedLabel(); + toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`); + return; + } + const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported; + state = normalizeState(expanded); + rebuildWorldIndex(); + hydrateAuthorUI(); + selectedAssetId = state.assets[0]?.id ?? null; + saveState(); + spriteCache.clear(); + hydrateRuntime(); + renderLibrary(); + updateSelectedLabel(); + toast('JSON imported.'); + } catch (error) { + toast('Invalid JSON.'); + console.error(error); + } + } + + function resetAll() { + if (!confirm('Reset all local assets and island objects?')) return; + localStorage.removeItem(STORAGE_KEY); + state = seedState(); + rebuildWorldIndex(); + hydrateAuthorUI(); + selectedAssetId = state.assets[0]?.id ?? null; + saveState(); + spriteCache.clear(); + hydrateRuntime(); + renderLibrary(); + updateSelectedLabel(); + toast('Local world reset.'); + } + + function recordSyncEvent(event) { + if (!event) return; + state.eventLog ||= []; + state.eventLog.push(event); + state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)); + state.sync ||= { lastEventId: null }; + state.sync.lastEventId = event.id; + } + + function cachePhase2State() { + if (!Phase2Sync?.cacheAssets) return; + Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); + if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) { + Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error)); + } + } + + function updateSyncStats() { + if (!els.syncStats) return; + const report = Phase2Sync?.compactSizeReport?.(state); + if (!report) { + els.syncStats.textContent = 'Phase 2 codec unavailable.'; + return; + } + const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet'; + const statsRect = getViewportWorldRect(64); + const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0; + els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`; + } + + function computeAssetContentHash(asset) { + const payload = [ + asset.category || '', + asset.subtype || '', + asset.size || '', + asset.pixels || '', + asset.faces?.right || '', + asset.faces?.left || '', + asset.meta?.depthPixels || '', + JSON.stringify(asset.meta?.lightPixels || []), + asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' + ].join('|'); + return `fnv1a:${fnv1a(payload)}`; + } + + function fnv1a(value) { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, '0'); + } + + function clampInt(value, min, max, fallback = min) { + const n = Math.round(Number(value)); + return Number.isFinite(n) ? clamp(n, min, max) : fallback; + } + + function findAsset(id) { + return state.assets.find((asset) => asset.id === id) || null; + } + + function blankPixels(size) { + return Array(size * size).fill(null); + } + + function normalizePixels(pixels, size) { + const out = blankPixels(size); + if (typeof pixels === 'string') { + for (let i = 0; i < Math.min(out.length, pixels.length); i++) { + const ch = pixels[i]; + out[i] = ch === '.' ? null : (PALETTE_BY_CODE[ch] ? ch : nearestPaletteCode(ch)); + } + return out; + } + if (!Array.isArray(pixels)) return out; + for (let i = 0; i < Math.min(out.length, pixels.length); i++) { + const value = pixels[i]; + if (!value) out[i] = null; + else if (PALETTE_BY_CODE[value]) out[i] = value; + else out[i] = nearestPaletteCode(value); + } + return out; + } + + function encodePixels(pixels) { + return normalizePixels(pixels, Math.sqrt(pixels.length) || editorSize).map((value) => value || '.').join(''); + } + + function colorToHex(value) { + if (!value) return 'rgba(0,0,0,0)'; + return PALETTE_BY_CODE[value] || value; + } + + function nearestPaletteCode(color) { + if (!color || typeof color !== 'string') return null; + if (PALETTE_BY_CODE[color]) return color; + const rgb = parseHex(color); + if (!rgb) return selectedColorCode; + let best = PALETTE[0].code; + let bestDist = Infinity; + for (const entry of PALETTE) { + const p = parseHex(entry.color); + const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2; + if (dist < bestDist) { bestDist = dist; best = entry.code; } + } + return best; + } + + function readableTextColor(hex) { + const rgb = parseHex(hex); + if (!rgb) return '#243044'; + const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; + return yiq > 140 ? '#243044' : '#fffdf5'; + } + + function parseHex(hex) { + if (typeof hex !== 'string' || !hex.startsWith('#')) return null; + const clean = hex.replace('#', ''); + const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean; + const value = parseInt(full, 16); + if (!Number.isFinite(value)) return null; + return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 }; + } + + + + function normalizeDepthPixels(input, size) { + const out = Array(size * size).fill(0); + if (typeof input === 'string') { + for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] === '1' ? 1 : 0; + return out; + } + if (!Array.isArray(input)) return out; + for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] ? 1 : 0; + return out; + } + + function encodeDepthPixels(input) { + return Array.from(input || []).map((v) => v ? '1' : '.').join(''); + } + + function resizeDepthPixels(source, oldSize, newSize) { + const normalized = normalizeDepthPixels(source, oldSize); + const out = Array(newSize * newSize).fill(0); + const min = Math.min(oldSize, newSize); + const xOffset = Math.floor((newSize - min) / 2); + const yOffset = Math.floor((newSize - min) / 2); + const oldOffset = Math.floor((oldSize - min) / 2); + for (let y = 0; y < min; y++) { + for (let x = 0; x < min; x++) { + out[(y + yOffset) * newSize + (x + xOffset)] = normalized[(y + oldOffset) * oldSize + (x + oldOffset)] || 0; + } + } + return out; + } + + function getAssetDepth(asset, x, y, side = 'right') { + const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); + const sx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - x : x; + return depth[y * asset.size + sx] || 0; + } + + function applyDepthToColor(hex, depth) { + if (!depth) return hex; + const rgb = parseHex(hex); + if (!rgb) return hex; + const amt = 34; + const r = Math.min(255, rgb.r + amt); + const g = Math.min(255, rgb.g + amt); + const b = Math.min(255, rgb.b + amt); + return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`; + } + + function resizePixels(source, oldSize, newSize) { + const out = blankPixels(newSize); + const min = Math.min(oldSize, newSize); + const xOffset = Math.floor((newSize - min) / 2); + const yOffset = Math.floor((newSize - min) / 2); + const oldOffset = Math.floor((oldSize - min) / 2); + for (let y = 0; y < min; y++) { + for (let x = 0; x < min; x++) { + out[(y + yOffset) * newSize + (x + xOffset)] = source[(y + oldOffset) * oldSize + (x + oldOffset)] || null; + } + } + return out; + } + + function mirrorPixels(pixels, size) { + const out = blankPixels(size); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + out[y * size + (size - 1 - x)] = pixels[y * size + x] || null; + } + } + return out; + } + + function hasAnyPixel(pixels) { + return Array.isArray(pixels) && pixels.some(Boolean); + } + + + function fade(t) { + return t * t * t * (t * (t * 6 - 15) + 10); + } + + function hash2(ix, iy) { + let h = ix * 374761393 + iy * 668265263; + h = (h ^ (h >> 13)) * 1274126177; + return (h ^ (h >> 16)) >>> 0; + } + + function gradDot(ix, iy, x, y) { + const h = hash2(ix, iy) & 7; + const gx = [1, -1, 1, -1, 1, -1, 0, 0][h]; + const gy = [1, 1, -1, -1, 0, 0, 1, -1][h]; + return gx * (x - ix) + gy * (y - iy); + } + + function perlinNoise(x, y) { + const x0 = Math.floor(x), y0 = Math.floor(y); + const x1 = x0 + 1, y1 = y0 + 1; + const sx = fade(x - x0), sy = fade(y - y0); + const n00 = gradDot(x0, y0, x, y); + const n10 = gradDot(x1, y0, x, y); + const n01 = gradDot(x0, y1, x, y); + const n11 = gradDot(x1, y1, x, y); + const ix0 = lerp(n00, n10, sx); + const ix1 = lerp(n01, n11, sx); + return lerp(ix0, ix1, sy) * .5 + .5; + } + + function fractalPerlin(x, y, octaves = 4) { + let amp = .5; + let freq = 1; + let total = 0; + let norm = 0; + for (let i = 0; i < octaves; i++) { + total += perlinNoise(x * freq, y * freq) * amp; + norm += amp; + amp *= .5; + freq *= 2; + } + return total / norm; + } + + function smoothNoise(x, y) { + return Math.sin(x * .71 + y * .37) * .5 + Math.sin(x * .23 - y * .61) * .32 + Math.sin((x + y) * .17) * .18; + } + + function cap(value) { + return `${value}`.charAt(0).toUpperCase() + `${value}`.slice(1); + } + + + function buildPalette() { + const neutrals = ['#fffdf7','#f3ead8','#dfd2bc','#c2b39d','#9b9083','#776d67','#5a5454','#403d42','#2d2d35','#14151c']; + const hues = [0, 24, 48, 72, 96, 132, 168, 204, 228, 264, 288, 324, 348]; + const lights = [72, 58, 46, 34]; + const colors = [...neutrals]; + for (const light of lights) { + for (const hue of hues) colors.push(hslToHex(hue, 72, light)); + } + return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] })); + } + + function pseudoNoise(value) { + const x = Math.sin(value * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); + } + + function hslToHex(h, s, l) { + s /= 100; l /= 100; + const k = (n) => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0'); + return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`; + } + + function hexToRgba(hex, alpha) { + const clean = hex.replace('#', ''); + const bigint = parseInt(clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + + function toast(message) { + clearTimeout(toastTimer); + els.toast.textContent = message; + els.toast.hidden = false; + toastTimer = setTimeout(() => { els.toast.hidden = true; }, 1900); + } + + function px(pixels, size, x, y, color) { + if (x >= 0 && x < size && y >= 0 && y < size) pixels[y * size + x] = color; + } + function rect(pixels, size, x, y, w, h, color) { + for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) px(pixels, size, xx, yy, color); + } + function circle(pixels, size, cx, cy, r, color) { + for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) { + for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) { + if ((x - cx) ** 2 + (y - cy) ** 2 <= r ** 2) px(pixels, size, x, y, color); + } + } + } + + function drawHouse() { + const s = 16, p = blankPixels(s); + rect(p, s, 4, 8, 8, 6, '#c57955'); + rect(p, s, 6, 10, 3, 4, '#423044'); + rect(p, s, 10, 9, 2, 2, '#ffdd7d'); + rect(p, s, 4, 7, 8, 1, '#9b5163'); + for (let y = 3; y <= 7; y++) { + for (let x = 3 + Math.abs(6 - y); x <= 12 - Math.abs(6 - y); x++) px(p, s, x, y, '#81495c'); + } + rect(p, s, 2, 8, 12, 1, '#654154'); + return p; + } + + function drawTree() { + const s = 16, p = blankPixels(s); + rect(p, s, 7, 9, 2, 5, '#805032'); + circle(p, s, 8, 5, 4, '#58b869'); + circle(p, s, 5, 7, 3, '#4aa95d'); + circle(p, s, 11, 7, 3, '#4aa95d'); + circle(p, s, 8, 8, 4, '#63ca75'); + px(p, s, 6, 5, '#8bef8f'); + px(p, s, 10, 4, '#8bef8f'); + return p; + } + + function drawLantern() { + const s = 16, p = blankPixels(s); + rect(p, s, 7, 5, 2, 8, '#514b60'); + rect(p, s, 5, 4, 6, 1, '#514b60'); + rect(p, s, 6, 5, 4, 4, '#b8e8ff'); + rect(p, s, 3, 13, 10, 1, '#86623f'); + px(p, s, 5, 9, '#514b60'); px(p, s, 10, 9, '#514b60'); + return p; + } + + function drawHumanRight() { + const s = 16, p = blankPixels(s); + rect(p, s, 7, 3, 3, 3, '#e6b887'); + rect(p, s, 6, 6, 5, 5, '#5d8bea'); + px(p, s, 10, 4, '#273046'); + rect(p, s, 5, 7, 1, 3, '#e6b887'); rect(p, s, 11, 7, 1, 3, '#e6b887'); + rect(p, s, 6, 11, 2, 3, '#31354f'); rect(p, s, 9, 11, 2, 3, '#31354f'); + rect(p, s, 6, 2, 5, 1, '#3a2b35'); + return p; + } + function drawHumanLeft() { return mirrorPixels(drawHumanRight(), 16); } + + function drawDogRight() { + const s = 16, p = blankPixels(s); + rect(p, s, 4, 8, 7, 4, '#b77a46'); + rect(p, s, 10, 7, 3, 3, '#c98952'); + px(p, s, 12, 8, '#2f1e17'); + rect(p, s, 5, 12, 1, 2, '#754b31'); rect(p, s, 9, 12, 1, 2, '#754b31'); + px(p, s, 3, 8, '#b77a46'); px(p, s, 2, 7, '#b77a46'); + return p; + } + function drawDogLeft() { return mirrorPixels(drawDogRight(), 16); } + + function drawFishRight() { + const s = 16, p = blankPixels(s); + rect(p, s, 5, 7, 6, 3, '#4bd7ff'); + px(p, s, 11, 8, '#e7fbff'); px(p, s, 4, 7, '#258ac0'); px(p, s, 3, 6, '#258ac0'); px(p, s, 3, 10, '#258ac0'); + px(p, s, 6, 6, '#90edff'); px(p, s, 8, 10, '#90edff'); + return p; + } + function drawFishLeft() { return mirrorPixels(drawFishRight(), 16); } + + function drawBirdRight() { + const s = 16, p = blankPixels(s); + rect(p, s, 7, 6, 3, 3, '#f4d45f'); + px(p, s, 10, 7, '#f09062'); + rect(p, s, 4, 6, 3, 1, '#6da9ef'); + rect(p, s, 10, 5, 3, 1, '#6da9ef'); + px(p, s, 8, 5, '#242434'); + return p; + } + function drawBirdLeft() { return mirrorPixels(drawBirdRight(), 16); } + + bootstrap(); +})(); diff --git a/index.html b/index.html index 186d8af..d3fe0a3 100644 --- a/index.html +++ b/index.html @@ -93,20 +93,47 @@ -
- - +
+ + + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + +
- + + + +
-
Palette color is used for pixels, lights, and depth marks.
+
Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.
@@ -140,10 +167,14 @@
Local save

Saved to this browser only. Export JSON if you want to move or share a local world.

- + + + +
+
@@ -168,6 +199,8 @@ + + diff --git a/index.html.bak b/index.html.bak new file mode 100644 index 0000000..7a6c57e --- /dev/null +++ b/index.html.bak @@ -0,0 +1,203 @@ + + + + + + Pixel Island Summoner - Local Prototype + + + +
+ + + + +
+
+ +
Local prototype / Draw pixels, summon them to islands.
+
+ +
+
Day
+
+
10 min = 1 day
+
+
+ + + +
+
+
+

Pixel Studio

+

Make static scenery or moving island life.

+
+ +
+ + + +
+
+
+
+ + +
+ + +
+ +
+ +
+ +
+
+
+
+ +
+ + + + + + + + + +
+
+ + + + + +
+
+ + + + + +
+
+ + + +
+
+ + + + +
+ +
Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.
+
+ +
+
Finish
+
+ + + +
+
+
+
+ +
+
+
+
Library
+ +
+

Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.

+ +
+
+
+ +
+
+
Local save
+

Saved to this browser only. Export JSON if you want to move or share a local world.

+
+ + + + + + +
+
+ +
+
+
+
+ + + +
+ + + + + + diff --git a/js/editor-actions.js b/js/editor-actions.js index c5afc5f..357e118 100644 --- a/js/editor-actions.js +++ b/js/editor-actions.js @@ -3,7 +3,40 @@ const root = window.PixelIslandModules ||= {}; + function clonePixels(pixels) { + return Array.isArray(pixels) ? [...pixels] : []; + } + + function floodFill(sourcePixels, size, x, y, colorCode) { + const pixels = clonePixels(sourcePixels); + const target = pixels[y * size + x] || null; + const replacement = colorCode || null; + if (target === replacement) return { pixels, changed: false, count: 0, cells: [] }; + + const stack = [[x, y]]; + const cells = []; + while (stack.length) { + const [cx, cy] = stack.pop(); + if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue; + const index = cy * size + cx; + if ((pixels[index] || null) !== target) continue; + pixels[index] = replacement; + cells.push({ x: cx, y: cy }); + stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); + } + return { pixels, changed: cells.length > 0, count: cells.length, cells }; + } + + function pickColor(sourcePixels, size, x, y) { + if (!Array.isArray(sourcePixels)) return null; + if (x < 0 || y < 0 || x >= size || y >= size) return null; + return sourcePixels[y * size + x] || null; + } + root.EditorActions = { + clonePixels, + floodFill, + pickColor, apply(snapshot, pushHistory, mutate) { pushHistory(snapshot()); mutate(); diff --git a/js/phase2-sync.js b/js/phase2-sync.js new file mode 100644 index 0000000..3798bf4 --- /dev/null +++ b/js/phase2-sync.js @@ -0,0 +1,402 @@ +(function () { + 'use strict'; + + const root = window.PixelIslandModules ||= {}; + const FORMAT = 'pixel-island-phase2-compact-v1'; + const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1'; + const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1'; + const EVENT_LOG_LIMIT = 300; + const DB_NAME = 'pixel-island-phase2-cache'; + const DB_VERSION = 1; + const ASSET_STORE = 'assets'; + const SNAPSHOT_STORE = 'snapshots'; + + function isCompactState(value) { + return Boolean(value && value.format === FORMAT && Array.isArray(value.assets)); + } + + function isAssetBundle(value) { + return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets)); + } + + function rleEncode(input) { + const text = String(input || ''); + if (!text) return ''; + let out = ''; + let last = text[0]; + let count = 1; + for (let i = 1; i < text.length; i++) { + const ch = text[i]; + if (ch === last) count++; + else { + out += `${count}:${last}`; + last = ch; + count = 1; + } + } + out += `${count}:${last}`; + return out; + } + + function rleDecode(input) { + const text = String(input || ''); + if (!text) return ''; + let out = ''; + let i = 0; + while (i < text.length) { + let digits = ''; + while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++]; + if (text[i] !== ':') break; + i++; + const ch = text[i++] || ''; + const count = Math.max(0, Number(digits) || 0); + out += ch.repeat(count); + } + return out; + } + + function normalizeEncodedPlane(value, size, emptyChar = '.') { + const total = Math.max(1, Number(size) || 1) ** 2; + const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : ''; + return (source + emptyChar.repeat(total)).slice(0, total); + } + + function cropPlane(encoded, size, emptyChar = '.') { + const text = normalizeEncodedPlane(encoded, size, emptyChar); + let minX = size; + let minY = size; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (text[y * size + x] !== emptyChar) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + if (maxX < 0) return { b: null, e: 'raw', v: '' }; + const w = maxX - minX + 1; + const h = maxY - minY + 1; + let cropped = ''; + for (let y = minY; y <= maxY; y++) { + cropped += text.slice(y * size + minX, y * size + minX + w); + } + const rle = rleEncode(cropped); + return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped }; + } + + function expandPlane(packed, size, emptyChar = '.') { + const total = Math.max(1, Number(size) || 1) ** 2; + const out = Array(total).fill(emptyChar); + if (!packed || !packed.b) return out.join(''); + const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0)); + const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || ''); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = y * w + x; + const dx = x0 + x; + const dy = y0 + y; + if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) { + out[dy * size + dx] = value[src] || emptyChar; + } + } + } + return out.join(''); + } + + function packAsset(asset) { + const size = Math.max(1, Number(asset.size) || 16); + const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; + const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.'); + const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : ''; + const lights = Array.isArray(asset.meta?.lightPixels) + ? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2]) + : []; + const meta = {}; + if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.'); + if (lights.length) meta.l = lights; + if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor; + if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0]; + + return { + id: asset.id, + h: asset.contentHash || asset.hash || null, + n: asset.name || 'Untitled', + c: category, + t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), + s: size, + p: cropPlane(right, size, '.'), + f: category === 'dynamic' ? { l: 'mirror' } : null, + pa: asset.parentAssetId || null, + oa: asset.originalAssetId || null, + ca: asset.createdAt || Date.now(), + ua: asset.updatedAt || asset.createdAt || Date.now(), + au: asset.author || 'Local Artist', + m: Object.keys(meta).length ? meta : null + }; + } + + function unpackAsset(packed) { + if (!packed || !packed.id) return null; + const size = Math.max(1, Number(packed.s || packed.size) || 16); + const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static'; + const pixels = expandPlane(packed.p, size, '.'); + const metaPacked = packed.m || {}; + const lightPixels = Array.isArray(metaPacked.l) + ? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' })) + : []; + const meta = { + hasLight: lightPixels.length > 0, + lightPixels, + lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null, + depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null, + door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null + }; + return { + id: packed.id, + name: packed.n || 'Untitled', + category, + subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'), + size, + pixels, + faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null, + parentAssetId: packed.pa || null, + originalAssetId: packed.oa || null, + createdAt: packed.ca || Date.now(), + updatedAt: packed.ua || packed.ca || Date.now(), + author: packed.au || 'Local Artist', + meta, + contentHash: packed.h || null + }; + } + + function packPlacement(item) { + return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1]; + } + + function unpackPlacement(row) { + if (!Array.isArray(row)) return row; + return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 }; + } + + function packDynamic(item) { + return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1]; + } + + function unpackDynamic(row) { + if (!Array.isArray(row)) return row; + return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 }; + } + + function compactState(state) { + return { + schema: 3, + format: FORMAT, + authorName: state.authorName || 'Local Artist', + assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [], + placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [], + dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [], + objectVotes: state.objectVotes || {}, + assetVotes: state.assetVotes || {}, + hiddenAssets: state.hiddenAssets || {}, + hiddenObjects: state.hiddenObjects || {}, + eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [], + sync: state.sync || { lastEventId: null } + }; + } + + function expandState(input) { + if (!isCompactState(input)) return input; + return { + schema: 3, + authorName: input.authorName || 'Local Artist', + assets: input.assets.map(unpackAsset).filter(Boolean), + placed: (input.placed || []).map(unpackPlacement), + dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic), + objectVotes: input.objectVotes || {}, + assetVotes: input.assetVotes || {}, + hiddenAssets: input.hiddenAssets || {}, + hiddenObjects: input.hiddenObjects || {}, + eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [], + sync: input.sync || { lastEventId: null } + }; + } + + function compactSizeReport(state) { + const full = JSON.stringify({ ...state, schema: 3 }); + const compact = JSON.stringify(compactState(state)); + return { + fullBytes: full.length, + compactBytes: compact.length, + savedBytes: Math.max(0, full.length - compact.length), + savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0, + assets: state.assets?.length || 0, + objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0) + }; + } + + function assetManifest(state) { + return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype })); + } + + function makeSnapshot(state, worldId = 'local-main') { + return { + schema: 3, + format: SNAPSHOT_FORMAT, + worldId, + createdAt: Date.now(), + manifest: assetManifest(state), + placed: (state.placed || []).map(packPlacement), + dynamicSummons: (state.dynamicSummons || []).map(packDynamic), + hiddenObjects: state.hiddenObjects || {} + }; + } + + function makeAssetBundle(state, assetIds) { + const wanted = new Set(assetIds || []); + const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset); + return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets }; + } + + function unpackAssetBundle(bundle) { + if (!isAssetBundle(bundle)) return []; + return bundle.assets.map(unpackAsset).filter(Boolean); + } + + function findMissingAssetIds(snapshot, knownAssetIds) { + const known = new Set(knownAssetIds || []); + return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id)); + } + + function makeEvent(type, payload = {}) { + return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload }; + } + + function createAssetUpsertEvent(asset) { + return makeEvent('asset.upsert', { asset: packAsset(asset) }); + } + + function createObjectUpsertEvent(kind, object) { + return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) }); + } + + function createObjectDeleteEvent(kind, objectId) { + return makeEvent('object.delete', { kind, objectId }); + } + + function applyEvent(state, event) { + if (!state || !event) return state; + if (event.type === 'asset.upsert' && event.asset) { + const asset = unpackAsset(event.asset); + if (!asset) return state; + const index = (state.assets || []).findIndex((a) => a.id === asset.id); + if (index >= 0) state.assets[index] = asset; + else (state.assets ||= []).unshift(asset); + } + if (event.type === 'object.upsert') { + if (event.kind === 'dynamic') { + const object = unpackDynamic(event.object); + const list = state.dynamicSummons ||= []; + const index = list.findIndex((item) => item.id === object.id); + if (index >= 0) list[index] = object; + else list.push(object); + } else { + const object = unpackPlacement(event.object); + const list = state.placed ||= []; + const index = list.findIndex((item) => item.id === object.id); + if (index >= 0) list[index] = object; + else list.push(object); + } + } + if (event.type === 'object.delete') { + const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed'; + state[key] = (state[key] || []).filter((item) => item.id !== event.objectId); + } + return state; + } + + function openDb() { + if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.')); + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function cacheAssets(assets) { + if (!Array.isArray(assets) || !assets.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(ASSET_STORE, 'readwrite'); + for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset)); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + async function readCachedAssets(assetIds) { + const ids = Array.from(assetIds || []); + if (!ids.length) return []; + const db = await openDb(); + const rows = await Promise.all(ids.map((id) => new Promise((resolve) => { + const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => resolve(null); + }))); + db.close(); + return rows.filter(Boolean).map(unpackAsset).filter(Boolean); + } + + async function cacheSnapshot(snapshot) { + if (!snapshot?.worldId) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(SNAPSHOT_STORE, 'readwrite'); + tx.objectStore(SNAPSHOT_STORE).put(snapshot); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + root.Phase2Sync = { + FORMAT, + SNAPSHOT_FORMAT, + ASSET_BUNDLE_FORMAT, + EVENT_LOG_LIMIT, + isCompactState, + isAssetBundle, + rleEncode, + rleDecode, + cropPlane, + expandPlane, + packAsset, + unpackAsset, + compactState, + expandState, + compactSizeReport, + assetManifest, + makeSnapshot, + makeAssetBundle, + unpackAssetBundle, + findMissingAssetIds, + makeEvent, + createAssetUpsertEvent, + createObjectUpsertEvent, + createObjectDeleteEvent, + applyEvent, + cacheAssets, + readCachedAssets, + cacheSnapshot + }; +})(); diff --git a/js/phase2-sync.js.bak b/js/phase2-sync.js.bak new file mode 100644 index 0000000..060efc8 --- /dev/null +++ b/js/phase2-sync.js.bak @@ -0,0 +1,402 @@ +(function () { + 'use strict'; + + const root = window.PixelIslandModules ||= {}; + const FORMAT = 'pixel-island-phase2-compact-v1'; + const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1'; + const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1'; + const EVENT_LOG_LIMIT = 300; + const DB_NAME = 'pixel-island-phase2-cache'; + const DB_VERSION = 1; + const ASSET_STORE = 'assets'; + const SNAPSHOT_STORE = 'snapshots'; + + function isCompactState(value) { + return Boolean(value && value.format === FORMAT && Array.isArray(value.assets)); + } + + function isAssetBundle(value) { + return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets)); + } + + function rleEncode(input) { + const text = String(input || ''); + if (!text) return ''; + let out = ''; + let last = text[0]; + let count = 1; + for (let i = 1; i < text.length; i++) { + const ch = text[i]; + if (ch === last) count++; + else { + out += `${count}:${last}`; + last = ch; + count = 1; + } + } + out += `${count}:${last}`; + return out; + } + + function rleDecode(input) { + const text = String(input || ''); + if (!text) return ''; + let out = ''; + let i = 0; + while (i < text.length) { + let digits = ''; + while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++]; + if (text[i] !== ':') break; + i++; + const ch = text[i++] || ''; + const count = Math.max(0, Number(digits) || 0); + out += ch.repeat(count); + } + return out; + } + + function normalizeEncodedPlane(value, size, emptyChar = '.') { + const total = Math.max(1, Number(size) || 1) ** 2; + const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : ''; + return (source + emptyChar.repeat(total)).slice(0, total); + } + + function cropPlane(encoded, size, emptyChar = '.') { + const text = normalizeEncodedPlane(encoded, size, emptyChar); + let minX = size; + let minY = size; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (text[y * size + x] !== emptyChar) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + if (maxX < 0) return { b: null, e: 'raw', v: '' }; + const w = maxX - minX + 1; + const h = maxY - minY + 1; + let cropped = ''; + for (let y = minY; y <= maxY; y++) { + cropped += text.slice(y * size + minX, y * size + minX + w); + } + const rle = rleEncode(cropped); + return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped }; + } + + function expandPlane(packed, size, emptyChar = '.') { + const total = Math.max(1, Number(size) || 1) ** 2; + const out = Array(total).fill(emptyChar); + if (!packed || !packed.b) return out.join(''); + const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0)); + const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || ''); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const src = y * w + x; + const dx = x0 + x; + const dy = y0 + y; + if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) { + out[dy * size + dx] = value[src] || emptyChar; + } + } + } + return out.join(''); + } + + function packAsset(asset) { + const size = Math.max(1, Number(asset.size) || 16); + const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; + const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.'); + const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : ''; + const lights = Array.isArray(asset.meta?.lightPixels) + ? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2]) + : []; + const meta = {}; + if (depth && /1/.test(depth)) meta.d = cropPlane(depth, size, '.'); + if (lights.length) meta.l = lights; + if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor; + if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0]; + + return { + id: asset.id, + h: asset.contentHash || asset.hash || null, + n: asset.name || 'Untitled', + c: category, + t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), + s: size, + p: cropPlane(right, size, '.'), + f: category === 'dynamic' ? { l: 'mirror' } : null, + pa: asset.parentAssetId || null, + oa: asset.originalAssetId || null, + ca: asset.createdAt || Date.now(), + ua: asset.updatedAt || asset.createdAt || Date.now(), + au: asset.author || 'Local Artist', + m: Object.keys(meta).length ? meta : null + }; + } + + function unpackAsset(packed) { + if (!packed || !packed.id) return null; + const size = Math.max(1, Number(packed.s || packed.size) || 16); + const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static'; + const pixels = expandPlane(packed.p, size, '.'); + const metaPacked = packed.m || {}; + const lightPixels = Array.isArray(metaPacked.l) + ? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' })) + : []; + const meta = { + hasLight: lightPixels.length > 0, + lightPixels, + lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null, + depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null, + door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null + }; + return { + id: packed.id, + name: packed.n || 'Untitled', + category, + subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'), + size, + pixels, + faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null, + parentAssetId: packed.pa || null, + originalAssetId: packed.oa || null, + createdAt: packed.ca || Date.now(), + updatedAt: packed.ua || packed.ca || Date.now(), + author: packed.au || 'Local Artist', + meta, + contentHash: packed.h || null + }; + } + + function packPlacement(item) { + return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1]; + } + + function unpackPlacement(row) { + if (!Array.isArray(row)) return row; + return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 }; + } + + function packDynamic(item) { + return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1]; + } + + function unpackDynamic(row) { + if (!Array.isArray(row)) return row; + return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 }; + } + + function compactState(state) { + return { + schema: 3, + format: FORMAT, + authorName: state.authorName || 'Local Artist', + assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [], + placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [], + dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [], + objectVotes: state.objectVotes || {}, + assetVotes: state.assetVotes || {}, + hiddenAssets: state.hiddenAssets || {}, + hiddenObjects: state.hiddenObjects || {}, + eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [], + sync: state.sync || { lastEventId: null } + }; + } + + function expandState(input) { + if (!isCompactState(input)) return input; + return { + schema: 3, + authorName: input.authorName || 'Local Artist', + assets: input.assets.map(unpackAsset).filter(Boolean), + placed: (input.placed || []).map(unpackPlacement), + dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic), + objectVotes: input.objectVotes || {}, + assetVotes: input.assetVotes || {}, + hiddenAssets: input.hiddenAssets || {}, + hiddenObjects: input.hiddenObjects || {}, + eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [], + sync: input.sync || { lastEventId: null } + }; + } + + function compactSizeReport(state) { + const full = JSON.stringify({ ...state, schema: 3 }); + const compact = JSON.stringify(compactState(state)); + return { + fullBytes: full.length, + compactBytes: compact.length, + savedBytes: Math.max(0, full.length - compact.length), + savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0, + assets: state.assets?.length || 0, + objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0) + }; + } + + function assetManifest(state) { + return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype })); + } + + function makeSnapshot(state, worldId = 'local-main') { + return { + schema: 3, + format: SNAPSHOT_FORMAT, + worldId, + createdAt: Date.now(), + manifest: assetManifest(state), + placed: (state.placed || []).map(packPlacement), + dynamicSummons: (state.dynamicSummons || []).map(packDynamic), + hiddenObjects: state.hiddenObjects || {} + }; + } + + function makeAssetBundle(state, assetIds) { + const wanted = new Set(assetIds || []); + const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset); + return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets }; + } + + function unpackAssetBundle(bundle) { + if (!isAssetBundle(bundle)) return []; + return bundle.assets.map(unpackAsset).filter(Boolean); + } + + function findMissingAssetIds(snapshot, knownAssetIds) { + const known = new Set(knownAssetIds || []); + return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id)); + } + + function makeEvent(type, payload = {}) { + return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload }; + } + + function createAssetUpsertEvent(asset) { + return makeEvent('asset.upsert', { asset: packAsset(asset) }); + } + + function createObjectUpsertEvent(kind, object) { + return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) }); + } + + function createObjectDeleteEvent(kind, objectId) { + return makeEvent('object.delete', { kind, objectId }); + } + + function applyEvent(state, event) { + if (!state || !event) return state; + if (event.type === 'asset.upsert' && event.asset) { + const asset = unpackAsset(event.asset); + if (!asset) return state; + const index = (state.assets || []).findIndex((a) => a.id === asset.id); + if (index >= 0) state.assets[index] = asset; + else (state.assets ||= []).unshift(asset); + } + if (event.type === 'object.upsert') { + if (event.kind === 'dynamic') { + const object = unpackDynamic(event.object); + const list = state.dynamicSummons ||= []; + const index = list.findIndex((item) => item.id === object.id); + if (index >= 0) list[index] = object; + else list.push(object); + } else { + const object = unpackPlacement(event.object); + const list = state.placed ||= []; + const index = list.findIndex((item) => item.id === object.id); + if (index >= 0) list[index] = object; + else list.push(object); + } + } + if (event.type === 'object.delete') { + const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed'; + state[key] = (state[key] || []).filter((item) => item.id !== event.objectId); + } + return state; + } + + function openDb() { + if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.')); + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + async function cacheAssets(assets) { + if (!Array.isArray(assets) || !assets.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(ASSET_STORE, 'readwrite'); + for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset)); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + async function readCachedAssets(assetIds) { + const ids = Array.from(assetIds || []); + if (!ids.length) return []; + const db = await openDb(); + const rows = await Promise.all(ids.map((id) => new Promise((resolve) => { + const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => resolve(null); + }))); + db.close(); + return rows.filter(Boolean).map(unpackAsset).filter(Boolean); + } + + async function cacheSnapshot(snapshot) { + if (!snapshot?.worldId) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(SNAPSHOT_STORE, 'readwrite'); + tx.objectStore(SNAPSHOT_STORE).put(snapshot); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + root.Phase2Sync = { + FORMAT, + SNAPSHOT_FORMAT, + ASSET_BUNDLE_FORMAT, + EVENT_LOG_LIMIT, + isCompactState, + isAssetBundle, + rleEncode, + rleDecode, + cropPlane, + expandPlane, + packAsset, + unpackAsset, + compactState, + expandState, + compactSizeReport, + assetManifest, + makeSnapshot, + makeAssetBundle, + unpackAssetBundle, + findMissingAssetIds, + makeEvent, + createAssetUpsertEvent, + createObjectUpsertEvent, + createObjectDeleteEvent, + applyEvent, + cacheAssets, + readCachedAssets, + cacheSnapshot + }; +})(); diff --git a/styles.css b/styles.css index 89eca49..c673c22 100644 --- a/styles.css +++ b/styles.css @@ -25,7 +25,7 @@ body { margin: 0; color: var(--ink); background: #92d8ff; - font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace; + font-family: "3x5 MT Pixel Font", "Press Start 2P", "PixelMplus10", "DotGothic16", "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace; overflow: hidden; } @@ -424,8 +424,13 @@ input[type="checkbox"] { width: auto; margin: 0 8px 0 0; } } +.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); } .toolRow input { margin: 0; } .tool { padding: 7px 5px; font-size: 11px; } +button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; } #paintCanvas { display: block; @@ -835,7 +840,8 @@ body, button, input, select, textarea { -webkit-font-smoothing: none; text-rendering: geometricPrecision; } -.toolRow { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); } .advancedRow { grid-template-columns: max-content max-content max-content 1fr; gap: 6px; @@ -879,5 +885,17 @@ body, button, input, select, textarea { .assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; } @media (max-width: 720px) { .assetSectionGrid { grid-template-columns: 1fr; max-height: none; } + .toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .editorHistoryRow { grid-template-columns: 1fr 1fr; } .advancedRow { grid-template-columns: 1fr 1fr 1fr; } } + + +.syncStats { + padding: 8px 10px; + border-radius: 12px; + background: rgba(20, 25, 32, 0.06); + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} diff --git a/styles.css.bak b/styles.css.bak new file mode 100644 index 0000000..8c3b475 --- /dev/null +++ b/styles.css.bak @@ -0,0 +1,901 @@ +:root { + --ink: #243044; + --muted: #6f7b91; + --paper: #fff7e8; + --panel: rgba(255, 248, 232, .94); + --panel-solid: #fff8e9; + --panel-2: #ffeec9; + --line: #2d3c50; + --line-soft: rgba(45, 60, 80, .18); + --pink: #ff85b3; + --pink-dark: #f05f98; + --mint: #73d6a4; + --sky: #77c9ff; + --sun: #ffd66e; + --danger: #ff6b6b; + --shadow: 6px 6px 0 rgba(34, 45, 63, .14); + --hard-shadow: 4px 4px 0 rgba(34, 45, 63, .22); +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + color: var(--ink); + background: #92d8ff; + font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace; + overflow: hidden; +} + +button, input, select, textarea { font: inherit; } +button { -webkit-tap-highlight-color: transparent; } + +.app { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; +} + +#worldCanvas { + display: block; + width: 100vw; + height: 100vh; + image-rendering: pixelated; + cursor: grab; + background: #8cd4ff; +} + +#worldCanvas.dragging { cursor: grabbing; } +#worldCanvas.placeCursor { cursor: copy; } +#worldCanvas.eraseCursor { cursor: not-allowed; } + +.hud { + position: absolute; + z-index: 5; + background: var(--panel); + border: 2px solid var(--line); + border-radius: 0; + box-shadow: var(--shadow); + backdrop-filter: blur(8px); +} + +.topHud { + top: 14px; + left: 14px; + right: 14px; + min-height: 70px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + pointer-events: none; +} + +.logo { + font-weight: 900; + letter-spacing: .04em; + font-size: 20px; +} + +.logo span { + display: inline-block; + background: var(--pink); + border: 2px solid var(--line); + padding: 1px 6px; + margin-left: 6px; + font-size: 12px; + transform: rotate(-2deg); +} + +.subline { + margin-top: 3px; + color: var(--muted); + font-size: 12px; +} + + +.authorCard { + width: 190px; + border: 2px solid var(--line); + background: #fffdf5; + padding: 6px 8px; + pointer-events: auto; +} +.authorCard span { + display: block; + font-size: 10px; + font-weight: 900; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--muted); +} +.authorCard input { + margin-top: 3px; + padding: 5px 6px; + border-width: 2px; + background: #fffaf0; +} + +.clockCard { + width: 230px; + border: 2px solid var(--line); + background: #fffdf5; + padding: 8px 10px; +} + +.phaseLabel { + font-weight: 900; + letter-spacing: .06em; + text-transform: uppercase; + font-size: 13px; +} + +.phaseBar { + margin-top: 6px; + height: 10px; + background: #dfe8f0; + border: 2px solid var(--line); + overflow: hidden; +} + +.phaseBar span { + display: block; + width: 0%; + height: 100%; + background: linear-gradient(90deg, #ffd66e, #77c9ff, #6862e8); +} + +.clockHint { + margin-top: 4px; + color: var(--muted); + font-size: 11px; +} + +.edgeAdd { + position: absolute; + z-index: 8; + left: 0; + top: 50%; + width: 56px; + height: 86px; + transform: translateY(-50%); + border: 3px solid var(--line); + border-left: 0; + border-radius: 0 18px 18px 0; + background: var(--pink); + color: white; + font-size: 42px; + font-weight: 900; + line-height: 1; + box-shadow: var(--hard-shadow); + cursor: pointer; +} + +.edgeAdd:hover { background: var(--pink-dark); } +.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); } + + .toolHud { + right: 14px; + top: 116px; + display: grid; + grid-template-columns: 1fr; + gap: 7px; + padding: 9px; +} + +.iconButton, +button, +.ghostButton, +.tab, +.tool, +.segmented button { + border: 2px solid var(--line); + border-radius: 0; + background: #fffdf5; + color: var(--ink); + padding: 8px 11px; + font-weight: 850; + cursor: pointer; + box-shadow: 3px 3px 0 rgba(36, 48, 68, .16); + transition: transform .05s, box-shadow .05s, background .12s; +} + +button:hover, +.ghostButton:hover, +.tab:hover, +.tool:hover, +.segmented button:hover { background: #fff1c8; } + +button:active, +.ghostButton:active, +.tab:active, +.tool:active, +.segmented button:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 rgba(36, 48, 68, .16); +} + +button.active, +.tab.active, +.tool.active, +.segmented button.active { + background: var(--sun); + box-shadow: inset 0 0 0 2px rgba(255,255,255,.4), 3px 3px 0 rgba(36,48,68,.18); +} + +button.primary { + background: var(--mint); + color: #163a2a; +} + +button.secondary { background: var(--panel-2); } +button.danger, .tool.danger, .iconButton.danger { background: #ffe4e4; color: #833232; } +button.danger.active, .iconButton.danger.active { background: var(--danger); color: #fffdf5; } + +.divider { + height: 2px; + background: var(--line); + opacity: .22; + margin: 2px 0; +} + +.infoHud { + right: 14px; + bottom: 14px; + width: min(360px, calc(100vw - 28px)); + padding: 12px; +} + +.selectedAsset { + font-weight: 900; + padding-bottom: 8px; + border-bottom: 2px dashed rgba(45,60,80,.25); +} + +.tileInfo { + white-space: pre-wrap; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin-top: 8px; +} + +.studioDrawer { + position: absolute; + z-index: 7; + top: 0; + left: 0; + width: min(500px, calc(100vw - 20px)); + height: 100vh; + background: var(--panel-solid); + border-right: 3px solid var(--line); + box-shadow: 10px 0 0 rgba(36, 48, 68, .12); + transform: translateX(-104%); + transition: transform .18s ease-out; + display: grid; + grid-template-rows: auto auto 1fr; +} + +.studioDrawer.open { transform: translateX(0); } + +.drawerHead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 10px 12px 8px; + border-bottom: 3px solid var(--line); + background: #ffe6f0; +} + +.drawerHead h1 { + margin: 0; + font-size: 24px; + font-weight: 950; +} + +.drawerHead p { + margin: 3px 0 0; + color: var(--muted); + font-size: 12px; +} + +.ghostButton { + width: 36px; + height: 36px; + padding: 0; + font-size: 24px; + line-height: 1; +} + +.tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + padding: 8px; + background: #fff2d0; + border-bottom: 3px solid var(--line); +} + +.tab { padding: 8px 5px; font-size: 13px; } + +.drawerBody { + overflow-y: auto; + padding: 8px; + background: + linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px, + var(--panel-solid); +} + +.tabPanel { display: none; } +.tabPanel.active { display: block; } + +.compactNameField { margin-bottom: 5px; } +.roleHintInline { margin: 0 0 10px; } + +.card { + background: #fffdf5; + border: 2px solid var(--line); + box-shadow: 4px 4px 0 rgba(36,48,68,.12); + padding: 9px; + margin-bottom: 8px; +} + +.stack > * + * { margin-top: 10px; } +.cardTitle { + font-weight: 950; + text-transform: uppercase; + letter-spacing: .06em; + font-size: 12px; + color: #3c4b60; +} + +.field { + display: block; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +input, select, textarea { + display: block; + width: 100%; + margin-top: 5px; + border: 2px solid var(--line); + border-radius: 0; + background: #fffaf0; + color: var(--ink); + padding: 9px 10px; + outline: none; +} + +input:focus, select:focus, textarea:focus { background: #fff; box-shadow: 0 0 0 3px rgba(119,201,255,.35); } +textarea { resize: vertical; } +input[type="color"] { height: 39px; padding: 3px; } +input[type="checkbox"] { width: auto; margin: 0 8px 0 0; } + +.twoCols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.segmented { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.segmented button { font-size: 12px; padding: 8px 5px; } + +.hint { + color: var(--muted); + font-size: 12px; + line-height: 1.5; + margin: 0; +} + +.editorCard { padding-bottom: 10px; } +.editorTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 9px; +} + +.sideSwitcher { + display: flex; + gap: 5px; + align-items: center; +} +.sideSwitcher button { padding: 6px 7px; font-size: 11px; } + +.toolRow { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; + align-items: center; + margin-bottom: 8px; +} + + +.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } +.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.toolRow input { margin: 0; } +.tool { padding: 7px 5px; font-size: 11px; } +button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; } + +#paintCanvas { + display: block; + width: min(100%, 330px); + margin: 0 auto; + aspect-ratio: 1 / 1; + image-rendering: pixelated; + border: 3px solid var(--line); + background: + linear-gradient(45deg, #f4ebdc 25%, transparent 25%) 0 0/16px 16px, + linear-gradient(45deg, transparent 75%, #f4ebdc 75%) 0 0/16px 16px, + #fffaf0; + cursor: crosshair; + user-select: none; + touch-action: none; +} + +.compactSettings .twoCols { align-items: center; } +.toggleLine { + color: var(--ink); + font-size: 13px; + font-weight: 850; + display: flex; + align-items: center; +} + +.behaviorNote { + border: 2px dashed rgba(45,60,80,.22); + padding: 8px; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.actionRow { + display: flex; + gap: 9px; + margin: 12px 0; +} +.actionRow.wrap { flex-wrap: wrap; } +.actionRow button { flex: 1; } +.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; } + +.assetList { display: grid; gap: 10px; } +.assetCard { + display: grid; + grid-template-columns: 68px 1fr; + gap: 10px; + border: 2px solid var(--line); + background: #fff9e8; + padding: 8px; + box-shadow: 3px 3px 0 rgba(36,48,68,.12); +} +.assetCard.selected { background: #e6ffef; } +.assetPreview { + width: 64px; + height: 64px; + image-rendering: pixelated; + background: #fff3d9; + border: 2px solid var(--line); +} +.assetMeta strong { display: block; font-size: 14px; } +.assetMeta span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.3; } +.assetActions { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; +} +.assetActions button { padding: 5px 7px; font-size: 11px; } + +.modeGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} +.modeGrid button { padding: 8px 5px; font-size: 12px; } + +.legend { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + font-size: 12px; + color: var(--muted); +} +.legend span { display: flex; align-items: center; gap: 6px; } +.legend i { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid var(--line); +} +.legend .water { background: #67b5d9; } +.legend .sand { background: #ead493; } +.legend .grass { background: #8bce76; } +.legend .highland { background: #a7be80; } + +#dataBox { min-height: 160px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; } + +.toast { + position: absolute; + z-index: 20; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + background: #fffdf5; + border: 3px solid var(--line); + box-shadow: var(--hard-shadow); + padding: 10px 14px; + font-weight: 900; +} + +[hidden] { display: none !important; } + +@media (max-width: 760px) { + .topHud { align-items: flex-start; flex-direction: column; right: 70px; } + .clockCard, .authorCard { width: 100%; } + .toolHud { top: auto; bottom: 122px; right: 10px; } + .infoHud { left: 10px; right: 10px; width: auto; } + .studioDrawer { width: calc(100vw - 18px); } + .toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); } +} + +/* refinements */ +.toolHud { + top: 142px; + right: 18px; +} +.edgeAdd { + top: calc(50% + 24px); + width: 62px; + height: 92px; + background: linear-gradient(180deg, #ff97c2, #ff75aa); +} +.studioDrawer { + width: min(520px, calc(100vw - 28px)); +} +.drawerHead { background: #ffe1ef; } +.drawerBody { + background: + linear-gradient(90deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px, + linear-gradient(0deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px, + #fff8e9; +} +.card { + border-width: 3px; + box-shadow: 5px 5px 0 rgba(36,48,68,.13); +} +.heroEditor { + background: #fffdf7; + border-color: #26364c; +} +#paintCanvas { + margin-bottom: 10px; + max-height: min(42vh, 330px); +} +.toolRow { + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin-top: 0; +} +.paletteGrid { + display: grid; + grid-template-columns: repeat(13, 1fr); + gap: 3px; + margin: 6px 0 6px; + padding: 6px; + border: 2px solid rgba(36,48,68,.28); + background: #fff6df; +} +.paletteSwatch { + position: relative; + aspect-ratio: 1 / 1; + min-height: 18px; + border: 2px solid rgba(36,48,68,.38); + box-shadow: 2px 2px 0 rgba(36,48,68,.13); + cursor: pointer; +} +.paletteSwatch.active { + outline: 3px solid #ff7aad; + outline-offset: 1px; + border-color: #26364c; +} +.paletteSwatch::after { + content: attr(data-code); + position: absolute; + right: 2px; + bottom: 0; + color: rgba(20,26,38,.52); + font-size: 7px; + font-weight: 900; + text-shadow: 0 1px 0 rgba(255,255,255,.7); +} +.quickSetupBar { margin-bottom: 6px; } +.settingsCard .segmented { margin-top: 2px; } +.markerRow { + display: grid; + gap: 6px; +} +.markerChip { + border: 2px dashed rgba(36,48,68,.25); + background: #fff6df; + padding: 8px; + font-size: 12px; + color: var(--muted); + font-weight: 800; +} + +.summonCard { + background: linear-gradient(180deg, #fffdf5, #fff2d7); +} +.finishActions { + display: grid; + grid-template-columns: 1.35fr .8fr .6fr; +} +.bigPrimary { + min-height: 48px; + font-size: 14px; + background: linear-gradient(180deg, #89e4ae, #65d596) !important; +} +.sideSwitcher button:first-child.active::after, +.sideSwitcher button:nth-child(2).active::after { + content: ""; + display: inline-block; + width: 6px; +} +.spawnPop { + pointer-events: none; +} +.selectedAsset { font-weight: 900; padding-bottom: 8px; border-bottom: 2px dashed rgba(45,60,80,.25); } +@media (max-width: 720px) { + .toolHud { top: auto; bottom: 126px; right: 10px; } + .finishActions { grid-template-columns: 1fr; } + .paletteGrid { grid-template-columns: repeat(8, 1fr); } +} + +.paletteGrid button { width: 100%; aspect-ratio: 1 / 1; padding: 0; min-height: 22px; } +.sideSwitcher button { white-space: nowrap; } +.heroEditor .hint { line-height: 1.35; } + + +.compactEditorTop { margin-bottom: 4px; min-height: 0; } +body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothing: none; } + +/* latest layout fixes */ +.studioDrawer { width: min(600px, calc(100vw - 24px)); } +.paintLayout { + display: grid; + grid-template-columns: minmax(220px, 360px) 1fr; + align-items: start; + gap: 10px; + margin: 4px 0 8px; +} +#paintCanvas { + width: 100%; + max-width: 360px; + max-height: none; + margin: 0; +} +.paletteGrid { + margin: 0; + grid-template-columns: repeat(6, 1fr); + align-content: start; + gap: 4px; + max-height: 360px; + overflow: hidden; +} +.paletteSwatch, .paletteGrid button { + min-height: 20px; +} +.sideSwitcher { justify-content: flex-start; } +.authorCard span { white-space: nowrap; } +@media (max-width: 720px) { + .paintLayout { grid-template-columns: 1fr; } + .paletteGrid { grid-template-columns: repeat(10, 1fr); max-height: none; } +} + +/* Selection bubble + outline controls */ +.palettePanel { display: grid; gap: 8px; } +.selectionBubble { + position: absolute; + left: 0; + top: 0; + z-index: 9; + min-width: 126px; + max-width: 210px; + padding: 8px; + background: #fffdf5; + border: 3px solid var(--line); + box-shadow: 4px 4px 0 rgba(36,48,68,.20); + pointer-events: auto; + text-align: center; +} +.selectionBubble::after { + content: ""; + position: absolute; + left: 50%; + bottom: -10px; + width: 14px; + height: 14px; + background: #fffdf5; + border-right: 3px solid var(--line); + border-bottom: 3px solid var(--line); + transform: translateX(-50%) rotate(45deg); +} +.bubbleName { + font-size: 12px; + font-weight: 950; + line-height: 1.2; + word-break: break-word; +} +.bubbleAuthor { + margin-top: 2px; + font-size: 10px; + color: var(--muted); + font-weight: 800; +} +.bubbleVotes { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 6px; + align-items: center; + margin-top: 6px; +} +.bubbleVotes button { + padding: 3px 7px; + min-width: 30px; + font-size: 11px; +} +#voteScore { + font-weight: 950; + font-size: 12px; + min-width: 20px; +} + +.bubbleRemixFrom, .bubbleRemixCount { + margin-top: 3px; + color: var(--muted); + font-size: 10px; + font-weight: 800; +} +.bubbleActions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 5px; + margin-top: 6px; +} +.bubbleActions button { + padding: 4px 6px; + font-size: 10px; +} +.bubbleVotes button.mutedVote, .assetActions button.mutedAction { + opacity: .45; +} +.bubbleVotes button.activeVote { + background: var(--sun); +} + +/* Pixel-ish UI refinements */ +body, button, input, select, textarea { + font-family: "Courier New", "Lucida Console", Monaco, monospace; + letter-spacing: .02em; +} +button, .tab, .tool, .iconButton, .cardTitle, .logo { + text-transform: uppercase; + font-weight: 900; +} +.libraryHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.hiddenAssetPanel { + border: 2px dashed rgba(36,48,68,.35); + background: #fff3d6; + padding: 8px; +} +.compactAssetList .assetCard { opacity: .82; } +.librarySectionTitle { + margin: 12px 0 6px; + padding: 5px 7px; + background: #ffe6f0; + border: 2px solid var(--line); + box-shadow: 3px 3px 0 rgba(36,48,68,.12); + font-size: 12px; + font-weight: 900; +} +.advancedRow { + display: grid; + grid-template-columns: max-content 1fr; + gap: 8px; + align-items: center; + margin-top: 8px; +} +.advancedOnly.active { background: #bde9ff; } +.depthLegend { + font-size: 10px; + color: var(--muted); +} + +/* Simplified current UI */ +body, button, input, select, textarea { + font-family: "MS Gothic", "Osaka-Mono", "DotumChe", "Courier New", ui-monospace, monospace; + letter-spacing: .02em; + -webkit-font-smoothing: none; + text-rendering: geometricPrecision; +} +.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.advancedRow { + grid-template-columns: max-content max-content max-content 1fr; + gap: 6px; +} +.librarySectionTitle { + display: block; + width: 100%; + text-align: left; + margin: 10px 0 6px; + cursor: pointer; +} +.librarySectionTitle::after { + content: " click: genre"; + float: right; + color: var(--muted); + font-size: 10px; +} +.assetSectionGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + max-height: 360px; + overflow-y: auto; + padding-right: 4px; +} +.libraryEmptyNote { + color: var(--muted); + font-size: 12px; + border: 2px dashed rgba(36,48,68,.2); + padding: 8px; + background: #fff9e8; +} +.assetSectionGrid .assetCard { + grid-template-columns: 54px 1fr; + gap: 8px; +} +.assetSectionGrid .assetPreview { + width: 52px; + height: 52px; +} +.assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; } +@media (max-width: 720px) { + .assetSectionGrid { grid-template-columns: 1fr; max-height: none; } + .toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .editorHistoryRow { grid-template-columns: 1fr 1fr; } + .advancedRow { grid-template-columns: 1fr 1fr 1fr; } +} + + +.syncStats { + padding: 8px 10px; + border-radius: 12px; + background: rgba(20, 25, 32, 0.06); + color: var(--muted); + font-size: 12px; + line-height: 1.45; +}