diff --git a/app.js b/app.js index 9cb911b..6416fec 100644 --- a/app.js +++ b/app.js @@ -4,8 +4,9 @@ console.info('Pixel Island Summoner loaded'); const STORAGE_KEY = 'pixel-island-summoner:phase6b'; - const LEGACY_STORAGE_KEYS = ['pixel-island-summoner:phase6a']; - const SAVE_SCHEMA = 27; + const LEGACY_STORAGE_KEYS = ['pixel-island-summoner:phase6a', 'pixel-island-summoner:phase6b:schema27']; + const SAVE_SCHEMA = 32; + const LOCAL_MANIFEST_FORMAT = 'pixel-island-local-manifest-v1'; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -24,6 +25,10 @@ const MAX_DYNAMIC_STEPS_PER_FRAME = 3; const MAX_SPRITE_CACHE_ENTRIES = 260; const DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER = 5; + const CURSOR_LIGHT_COLOR = '#dff6ff'; + const CURSOR_LIGHT_WORLD_RADIUS = 86; + const CURSOR_LIGHT_INTENSITY = 0.625; + const CURSOR_DEPTH_REFLECTION_MULTIPLIER = 1.85; const BASE_COLOR_CODES = '0123456789abcdefghij'; const ADVANCED_COLOR_CODES = 'klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~"; const COLOR_CODES = BASE_COLOR_CODES + ADVANCED_COLOR_CODES; @@ -49,6 +54,22 @@ 'Butterfly Fish' ]); + const DEFAULT_GALLERY_KEEP_ASSET_NAMES = new Set([ + 'Pine Cluster', + 'Lantern Walker', + 'Coral Skiff', + 'Wildflower Patch', + 'Bridge Mechanic', + 'Town Gardener', + 'Lantern Courier', + 'Plaza Musician', + 'Desert Train', + 'Tideglass Lighthouse', + 'Rosetta Stela', + 'Rain Bell Tower', + 'Puddle Toad' + ]); + const editorActions = () => MODULES.EditorActions || null; const $ = (id) => document.getElementById(id); @@ -246,6 +267,7 @@ let world = makeWorld(); let terrainCache = buildTerrainCache(world); + let resetPhase2CacheOnBoot = false; let state = loadState(); let selectedAssetId = state.assets[0]?.id ?? null; let mode = 'inspect'; @@ -308,6 +330,9 @@ let libraryFilter = 'all'; let librarySearch = ''; let libraryViewTab = 'mine'; + let libraryScrollTop = 0; + let libraryScrollLockTop = null; + let suppressLibraryScrollCapture = 0; let defaultGalleryAssetNames = null; let renderPhase = null; let editorView = { zoom: 1, x: 0, y: 0 }; @@ -324,9 +349,10 @@ let shapePreview = null; let placementPreview = null; - function bootstrap() { + async function bootstrap() { resizeCanvas(); resetView(false); + await restoreStateFromIndexedDb(); rebuildWorldIndex(); hydrateRuntime(); coastalFoamTextures = buildCoastalFoamTextures(); @@ -359,6 +385,12 @@ button.addEventListener('click', () => setTab(button.dataset.tab, { preserveScroll: button.dataset.tab === 'library' })); }); + drawerScroller()?.addEventListener('scroll', () => { + if (suppressLibraryScrollCapture > 0 || activeTabName !== 'library') return; + const top = drawerScroller()?.scrollTop; + if (Number.isFinite(top)) libraryScrollTop = top; + }, { passive: true }); + els.authorName?.addEventListener('input', () => { const fallback = state.account?.id || 'Local Artist'; state.authorName = (els.authorName.value || fallback).trim() || fallback; @@ -589,10 +621,44 @@ setDrawerOpen(false); } + function drawerScroller() { + return els.drawer?.querySelector('.drawerBody') || null; + } + + function captureLibraryScroll() { + const top = drawerScroller()?.scrollTop; + if (Number.isFinite(top) && suppressLibraryScrollCapture <= 0) libraryScrollTop = top; + return Number.isFinite(libraryScrollTop) ? libraryScrollTop : 0; + } + + function preferredLibraryScrollTop(fallback = null) { + if (Number.isFinite(libraryScrollLockTop)) return libraryScrollLockTop; + if (Number.isFinite(fallback)) return fallback; + return Number.isFinite(libraryScrollTop) ? libraryScrollTop : 0; + } + + function holdLibraryScroll(scrollTop, work) { + const top = Number.isFinite(scrollTop) ? scrollTop : captureLibraryScroll(); + libraryScrollTop = top; + libraryScrollLockTop = top; + suppressLibraryScrollCapture++; + try { + return work?.(); + } finally { + restoreDrawerScroll(top); + requestAnimationFrame(() => restoreDrawerScroll(top)); + setTimeout(() => restoreDrawerScroll(top), 0); + setTimeout(() => { + restoreDrawerScroll(top); + suppressLibraryScrollCapture = Math.max(0, suppressLibraryScrollCapture - 1); + if (suppressLibraryScrollCapture === 0) libraryScrollLockTop = null; + }, 180); + } + } + function setTab(name, options = {}) { - const scroller = els.drawer?.querySelector('.drawerBody'); const keepScroll = Boolean(options.preserveScroll); - const scrollTop = keepScroll ? scroller?.scrollTop : null; + const scrollTop = keepScroll ? preferredLibraryScrollTop(drawerScroller()?.scrollTop) : null; activeTabName = 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}`)); @@ -1118,7 +1184,6 @@ target.worldMode = target.worldMode === 'shared' ? 'shared' : 'local'; target.serverSync = { lastServerEventId: target.serverSync?.lastServerEventId || null, - pendingCommands: Array.isArray(target.serverSync?.pendingCommands) ? target.serverSync.pendingCommands.slice(-300) : [], authority: { ...DEFAULT_SERVER_AUTHORITY, ...(target.serverSync?.authority || {}) }, clock: target.serverSync?.clock && typeof target.serverSync.clock === 'object' ? { ...target.serverSync.clock } : { worldTimeMs: Date.now(), syncedAt: Date.now() }, dynamicTargets: target.serverSync?.dynamicTargets && typeof target.serverSync.dynamicTargets === 'object' ? { ...target.serverSync.dynamicTargets } : {}, @@ -1236,8 +1301,7 @@ function queueSharedCommand(command, options = {}) { if (!command) return; ensureWorldProtectionState(); - state.serverSync.pendingCommands.push(command); - state.serverSync.pendingCommands = state.serverSync.pendingCommands.slice(-300); + Phase2Sync?.cacheOutboxCommand?.(command).catch((error) => console.warn('Phase 2 outbox cache failed.', error)); if (command.object?.id && command.object?.assetId) { const visualKind = command.kind === 'dynamic' ? 'dynamic' : 'static'; const existing = visualKind === 'dynamic' @@ -1447,7 +1511,7 @@ if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; selectedAssetId = assetId; updateSelectedLabel(); - renderLibrary(); + renderLibrary({ preserveScroll: true }); render(selectedAt); updateSelectionBubble(selectedAt); } @@ -3048,11 +3112,10 @@ return out; } - function saveAssetFromEditor() { + function buildEditorAssetRecord() { 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) { @@ -3066,14 +3129,19 @@ const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`; const aligned = alignEditorStateToBottom(); const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); + const savedWidth = aligned.width || savedSize; + const savedHeight = aligned.height || savedSize; + const encodedRight = encodePixels(aligned.rightPixels, savedWidth, savedHeight); + const pixelBlob = buildPixelBlob(encodedRight, savedWidth, savedHeight); const asset = { id: existing?.id || uid(), name, category, subtype, size: savedSize, - width: aligned.width || savedSize, - height: aligned.height || savedSize, + width: savedWidth, + height: savedHeight, + blobId: pixelBlob.id, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now(), author: existing?.author || state.authorName || 'Local Artist', @@ -3081,29 +3149,53 @@ version: Number(existing?.version || 0) + 1, parentAssetId: existing ? existing.parentAssetId : editParentId, originalAssetId: existing ? existing.originalAssetId : editOriginalId, - pixels: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), + pixels: encodedRight, faces: category === 'dynamic' ? { - right: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), + right: encodedRight, left: 'mirror' } : null, - meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, aligned.width || savedSize, aligned.particlePixels, aligned.rightPixels, aligned.height || savedSize) + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, savedWidth, aligned.particlePixels, aligned.rightPixels, savedHeight) }; asset.contentHash = computeAssetContentHash(asset); - const duplicate = existing ? null : findEquivalentCollectionAsset(asset); - if (duplicate) { - selectedAssetId = duplicate.id; - toast(`${duplicate.name} is already in Collection.`); - return duplicate; + return { asset, pixelBlob, existing, aligned }; + } + + function cacheAssetRecord(asset, pixelBlob) { + if (Phase2Sync?.cachePixelBlobs && pixelBlob) { + Phase2Sync.cachePixelBlobs([pixelBlob]).catch((error) => console.warn('Phase 2 pixel blob cache failed.', error)); } + if (Phase2Sync?.cacheAssets && asset) { + Phase2Sync.cacheAssets([asset]).catch((error) => console.warn('Phase 2 asset metadata cache failed.', error)); + } + } + + function persistAssetRecord(record) { + if (!record?.asset) return null; + const { asset, pixelBlob, existing, aligned } = record; 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(aligned.mirroredFromLeft ? `${asset.name} saved. Left-facing canvas was mirrored into right-facing movement.` : `${asset.name} saved.`); + toast(aligned?.mirroredFromLeft ? `${asset.name} saved. Left-facing canvas was mirrored into right-facing movement.` : `${asset.name} saved.`); } selectedAssetId = asset.id; + cacheAssetRecord(asset, pixelBlob); + return asset; + } + + function saveAssetFromEditor() { + const record = buildEditorAssetRecord(); + if (!record) return null; + const { asset, existing } = record; + const duplicate = existing ? null : findEquivalentCollectionAsset(asset); + if (duplicate) { + selectedAssetId = duplicate.id; + toast(`${duplicate.name} is already in Collection.`); + return duplicate; + } + persistAssetRecord(record); recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); editParentId = null; editOriginalId = null; @@ -3145,13 +3237,17 @@ } const aligned = alignEditorStateToBottom(); const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); + const savedWidth = aligned.width || savedSize; + const savedHeight = aligned.height || savedSize; + const encodedRight = encodePixels(aligned.rightPixels, savedWidth, savedHeight); + const pixelBlob = buildPixelBlob(encodedRight, savedWidth, savedHeight); const asset = { id: `preview:${Date.now()}`, name: (els.assetName.value || '').trim() || 'Preview work', - category, subtype, size: savedSize, width: aligned.width || savedSize, height: aligned.height || savedSize, author: state.authorName || 'Local Artist', - pixels: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), - faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), left: 'mirror' } : null, - meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, aligned.width || savedSize, aligned.particlePixels, aligned.rightPixels, aligned.height || savedSize) + category, subtype, size: savedSize, width: savedWidth, height: savedHeight, blobId: pixelBlob.id, author: state.authorName || 'Local Artist', + pixels: encodedRight, + faces: category === 'dynamic' ? { right: encodedRight, left: 'mirror' } : null, + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, savedWidth, aligned.particlePixels, aligned.rightPixels, savedHeight) }; return asset; } @@ -3324,10 +3420,16 @@ setMode('inspect'); } - function renderLibrary() { - els.assetList.innerHTML = ''; + function renderLibrary(options = {}) { + const preserveScroll = options.preserveScroll ?? Boolean(els.drawer?.classList.contains('open')); + const preservedScrollTop = preserveScroll ? preferredLibraryScrollTop(drawerScroller()?.scrollTop) : null; + const finishStableScroll = preserveScroll && Number.isFinite(preservedScrollTop) + ? stabilizeLibraryScrollSpace(preservedScrollTop) + : () => {}; + if (preserveScroll && Number.isFinite(preservedScrollTop)) libraryScrollTop = preservedScrollTop; + els.assetList.replaceChildren(); if (els.likedCodex) { - els.likedCodex.innerHTML = ''; + els.likedCodex.replaceChildren(); els.likedCodex.hidden = true; } const visibleAssets = state.assets.filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id]); @@ -3336,6 +3438,7 @@ renderCollectionTabs({ mine: [], others: [], favorite: [] }); els.assetList.append(makeLibraryEmptyNote('No visible assets.')); renderHiddenAssets(); + finishStableScroll(); return; } const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter; @@ -3356,6 +3459,7 @@ else if (libraryViewTab === 'others') addLibrarySection('Others', others, 'No assets in this tab.'); else addLibrarySection('Favorite', favorite, 'Works you upvote appear here.'); renderHiddenAssets(); + finishStableScroll(); } function renderLibraryToolbar(visibleAssets) { @@ -3376,8 +3480,9 @@ search.addEventListener('input', () => { librarySearch = search.value || ''; const caret = search.selectionStart || librarySearch.length; - renderLibrary(); + renderLibrary({ preserveScroll: true }); requestAnimationFrame(() => { + restoreDrawerScroll(preferredLibraryScrollTop()); const nextSearch = els.assetList?.querySelector('.collectionSearch'); if (!nextSearch) return; nextSearch.focus?.({ preventScroll: true }); @@ -3395,10 +3500,11 @@ button.classList.toggle('active', libraryFilter === filter); button.textContent = filter === 'all' ? 'All' : cap(filter); button.addEventListener('click', () => { - const scrollTop = els.drawer?.querySelector('.drawerBody')?.scrollTop; - libraryFilter = filter; - renderLibrary(); - restoreDrawerScroll(scrollTop); + const scrollTop = captureLibraryScroll(); + holdLibraryScroll(scrollTop, () => { + libraryFilter = filter; + renderLibrary({ preserveScroll: true }); + }); }); chips.append(button); } @@ -3421,10 +3527,11 @@ button.classList.toggle('active', libraryViewTab === key); button.textContent = `${label} ${groups[key]?.length ?? 0}`; button.addEventListener('click', () => { - const scrollTop = els.drawer?.querySelector('.drawerBody')?.scrollTop; - libraryViewTab = key; - renderLibrary(); - restoreDrawerScroll(scrollTop); + const scrollTop = captureLibraryScroll(); + holdLibraryScroll(scrollTop, () => { + libraryViewTab = key; + renderLibrary({ preserveScroll: true }); + }); }); tabs.append(button); } @@ -3455,10 +3562,35 @@ } function restoreDrawerScroll(scrollTop) { - const scroller = els.drawer?.querySelector('.drawerBody'); + const scroller = drawerScroller(); if (!scroller || !Number.isFinite(scrollTop)) return; - scroller.scrollTop = scrollTop; - requestAnimationFrame(() => { scroller.scrollTop = scrollTop; }); + const top = Math.max(0, scrollTop); + const apply = () => { + if (!drawerScroller()) return; + drawerScroller().scrollTop = top; + }; + apply(); + requestAnimationFrame(apply); + requestAnimationFrame(() => requestAnimationFrame(apply)); + } + + function stabilizeLibraryScrollSpace(scrollTop) { + const scroller = drawerScroller(); + const list = els.assetList; + if (!scroller || !list || !Number.isFinite(scrollTop)) return () => {}; + const minimumListHeight = Math.max(list.offsetHeight || 0, scroller.scrollHeight || 0, scroller.clientHeight + scrollTop + 24); + const previousMinHeight = list.style.minHeight; + list.style.minHeight = `${Math.ceil(minimumListHeight)}px`; + return () => { + restoreDrawerScroll(scrollTop); + requestAnimationFrame(() => { + restoreDrawerScroll(scrollTop); + requestAnimationFrame(() => { + restoreDrawerScroll(scrollTop); + list.style.minHeight = previousMinHeight; + }); + }); + }; } function makeAssetCard(asset) { @@ -3490,12 +3622,13 @@ meta.append(statusRow); card.addEventListener('click', () => { - const scrollTop = els.drawer?.querySelector('.drawerBody')?.scrollTop; - selectedAssetId = expanded ? null : asset.id; - updateSelectedLabel(); - renderLibrary(); - restoreDrawerScroll(scrollTop); - if (selectedAssetId) focusAssetInWorld(asset); + const scrollTop = captureLibraryScroll(); + holdLibraryScroll(scrollTop, () => { + selectedAssetId = expanded ? null : asset.id; + updateSelectedLabel(); + renderLibrary({ preserveScroll: true }); + if (selectedAssetId) focusAssetInWorld(asset); + }); }); if (expanded) { @@ -3523,9 +3656,10 @@ const edit = isMine ? makeButton('Edit', (event) => { event?.stopPropagation?.(); editOriginalAsset(asset); }) : null; const move = isMine ? makeButton('Place/Move', (event) => { event?.stopPropagation?.(); + const scrollTop = captureLibraryScroll(); selectedAssetId = asset.id; updateSelectedLabel(); - renderLibrary(); + holdLibraryScroll(scrollTop, () => renderLibrary({ preserveScroll: true })); setMode('place'); setDrawerOpen(false); toast('Click a valid tile to place or move it.'); @@ -3902,9 +4036,12 @@ }; } + const deletedBlobIds = assetsToDelete.map((item) => item.blobId).filter(Boolean); state.assets = state.assets.filter((a) => !deletedAssetIds.has(a.id)); state.placed = state.placed.filter((p) => !deletedAssetIds.has(p.assetId)); state.dynamicSummons = state.dynamicSummons.filter((p) => !deletedAssetIds.has(p.assetId)); + Phase2Sync?.deleteCachedAssets?.([...deletedAssetIds], deletedBlobIds) + .catch((error) => console.warn('Phase 2 cached asset delete failed.', error)); for (const assetId of deletedAssetIds) { delete state.assetVotes?.[assetId]; @@ -4520,14 +4657,14 @@ const sx = cursorPoint.x; const sy = cursorPoint.y; const pulse = 0.9 + Math.sin(time / 420) * 0.1; - const radius = Math.max(28, 54 * view.zoom) * pulse; + const radius = Math.max(44, CURSOR_LIGHT_WORLD_RADIUS * view.zoom) * pulse; ctx.save(); ctx.globalCompositeOperation = 'screen'; const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius); - glow.addColorStop(0, 'rgba(198, 236, 255, .16)'); - glow.addColorStop(0.22, 'rgba(170, 225, 255, .09)'); - glow.addColorStop(0.6, 'rgba(142, 218, 255, .035)'); - glow.addColorStop(1, 'rgba(142, 218, 255, 0)'); + glow.addColorStop(0, 'rgba(223, 246, 255, .15)'); + glow.addColorStop(0.2, 'rgba(190, 236, 255, .085)'); + glow.addColorStop(0.62, 'rgba(150, 220, 255, .032)'); + glow.addColorStop(1, 'rgba(150, 220, 255, 0)'); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(sx, sy, radius, 0, Math.PI * 2); @@ -4664,7 +4801,7 @@ function drawSpriteItem(item, time, lightSources, underwater, phase, drawShadow = true) { const info = getSpriteDrawInfo(item, time, true); - const localLights = getLocalSpriteLights(info, phase); + const localLights = getLocalSpriteLights(info, phase, lightSources, item.source?.id || info.asset?.id); const litSprite = localLights.length ? getSpriteCanvas(info.asset, info.side, localLights) : info.sprite; info.sprite = litSprite; const { asset, pos, sprite, drawX, drawY, alpha, angle, stretchX = 1, stretchY = 1 } = info; @@ -4693,7 +4830,8 @@ } ctx.restore(); - drawSpriteLightReflection(info, item, lightSources, phase); + // External sprite lighting is now handled inside shadeAssetPixelColor() via getLocalSpriteLights(). + // Keeping the old overlay pass here would double-apply non-cursor lights and waste a per-pixel scan. } @@ -4716,28 +4854,53 @@ return null; } - function getLocalSpriteLights(info, phase) { + function getLocalSpriteLights(info, phase, externalSources = [], selfOwnerId = null) { if (visualSettings().enableLights === false || !info?.asset) return []; - const lights = areNightLightsActive(phase) ? getAssetLightPointsForSide(info.asset, info.side) : []; + const asset = info.asset; + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const w = assetWidth(asset); + const h = assetHeight(asset); + const lights = areNightLightsActive(phase) + ? getAssetLightPointsForSide(asset, info.side).map((light) => ({ ...light, ownerId: selfOwnerId || light.ownerId || asset.id })) + : []; + + const addWorldLight = (source, options = {}) => { + if (!source || (source.ownerId && selfOwnerId && source.ownerId === selfOwnerId)) return; + const local = spriteLocalPoint(info, source.x, source.y); + const lx = local.x / scale - 0.5; + const ly = local.y / scale - 0.5; + const radiusCells = options.cursor + ? Math.max(4.0, ((Number(source.radius) || 18) / Math.max(1, scale)) * 3.1) + : Math.max(2.2, ((Number(source.radius) || 18) / Math.max(1, scale)) * 2.35); + const nearestX = clamp(lx, 0, Math.max(0, w - 1)); + const nearestY = clamp(ly, 0, Math.max(0, h - 1)); + const distToBounds = Math.hypot(lx - nearestX, ly - nearestY); + const margin = radiusCells * 1.15; + const proximity = Math.pow(clamp(1 - distToBounds / Math.max(1, margin), 0, 1), options.cursor ? 0.92 : 1.35); + if (proximity <= 0.01) return; + lights.push({ + x: lx, + y: ly, + c: options.cursor ? CURSOR_LIGHT_COLOR : (source.color || source.c || '#ffd86a'), + cursor: Boolean(options.cursor), + externalLight: !options.cursor, + ownerId: source.ownerId || null, + intensity: options.cursor + ? clamp((Number(source.intensity ?? 1) || 1) * (0.95 + proximity * 1.45), 1.05, 2.45) + : clamp((Number(source.intensity ?? 1) || 1) * (0.44 + proximity * 0.78), 0.22, 1.45), + radiusCells + }); + }; + + if (areNightLightsActive(phase) && Array.isArray(externalSources)) { + for (const source of externalSources) addWorldLight(source); + } + const cursorPoint = getCursorLightScreenPoint(phase); if (cursorPoint) { const worldX = (cursorPoint.x - view.x) / view.zoom; const worldY = (cursorPoint.y - view.y) / view.zoom; - const local = spriteLocalPoint(info, worldX, worldY); - const scale = info.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const lx = local.x / scale - .5; - const ly = local.y / scale - .5; - const w = assetWidth(info.asset); - const h = assetHeight(info.asset); - const reach = Math.max(4.5, Math.max(w, h) * 0.86); - const margin = reach * 1.35; - const nearestX = clamp(lx, 0, Math.max(0, w - 1)); - const nearestY = clamp(ly, 0, Math.max(0, h - 1)); - const distToBounds = Math.hypot(lx - nearestX, ly - nearestY); - const spriteProximity = Math.pow(clamp(1 - distToBounds / Math.max(1, margin), 0, 1), 1.85); - if (spriteProximity > 0.01) { - lights.push({ x: lx, y: ly, c: '#d6ecff', cursor: true, intensity: 0.3 + spriteProximity * 0.82 }); - } + addWorldLight({ x: worldX, y: worldY, color: CURSOR_LIGHT_COLOR, radius: Math.max(44, CURSOR_LIGHT_WORLD_RADIUS * view.zoom) / Math.max(0.001, view.zoom), intensity: CURSOR_LIGHT_INTENSITY }, { cursor: true }); } return lights; } @@ -4795,91 +4958,7 @@ ctx.restore(); } - function drawSpriteLightReflection(info, item, lightSources, phase) { - if (visualSettings().enableLights === false || !areNightLightsActive(phase) || !lightSources?.length) return; - const asset = item.asset; - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const sprite = info.sprite; - const sources = []; - const selfOwnerId = item.source?.id || asset.id; - for (const source of lightSources) { - if (source.ownerId && source.ownerId === selfOwnerId) continue; - const local = spriteLocalPoint(info, source.x, source.y); - const dx = local.x - sprite.width / 2; - const dy = local.y - sprite.height / 2; - const reach = source.radius * 4.9 + Math.max(sprite.width, sprite.height) * 1.15; - const distance = Math.hypot(dx, dy); - if (distance <= reach) sources.push({ source, local, distance }); - } - if (!sources.length) return; - sources.sort((a, b) => a.distance - b.distance); - const aw = assetWidth(asset); - const ah = assetHeight(asset); - const pixels = getAssetPixels(asset, info.side || 'right'); - const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], aw, ah); - const depths = asset.category === 'dynamic' && info.side === 'left' ? mirrorDepthPixels(rawDepths, aw, ah) : rawDepths; - const overlay = document.createElement('canvas'); - overlay.width = sprite.width; - overlay.height = sprite.height; - const octx = overlay.getContext('2d'); - octx.imageSmoothingEnabled = false; - - for (let y = 0; y < ah; y++) { - for (let x = 0; x < aw; x++) { - const pixelCode = pixels[y * aw + x]; - if (!pixelCode) continue; - const depth = depths[y * aw + x] || 0; - if (!depth) continue; - const baseRgb = parseHex(colorToHex(pixelCode)); - let rr = 0, gg = 0, bb = 0, aa = 0; - for (const { source, local } of sources) { - if (source.ownerId && source.ownerId === (item.source?.id || item.asset?.id)) continue; - const lc = parseHex(source.color || '#ffd86a'); - if (!lc) continue; - const cellPx = (x + 0.5) * scale; - const cellPy = (y + 0.5) * scale; - const d = Math.hypot(local.x - cellPx, local.y - cellPy); - const reach = Math.max(scale * 2.1, source.radius * 2.35); - const t = 1 - clamp(d / reach, 0, 1); - if (t <= 0) continue; - const edge = getDepthLightFacing(depth, x, y, aw, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5, ah); - const depthFactor = depth > 0 ? 0.28 + edge * 0.36 : 0.18 + edge * 0.16; - const sourceIntensity = clamp(Number(source.intensity ?? 1) || 1, 0.18, 1.15); - const falloff = Math.pow(t, 2.2) * sourceIntensity; - const amount = falloff * depthFactor; - const br = baseRgb?.r ?? lc.r; - const bg = baseRgb?.g ?? lc.g; - const bb0 = baseRgb?.b ?? lc.b; - rr += (lc.r * 0.82 + br * 0.18) * amount; - gg += (lc.g * 0.82 + bg * 0.18) * amount; - bb += (lc.b * 0.82 + bb0 * 0.18) * amount; - aa += amount; - } - if (aa <= 0.002) continue; - const inv = 1 / aa; - const alpha = Math.min(0.13, 0.012 + Math.pow(aa, 1.08) * 0.055); - octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${alpha.toFixed(3)})`; - octx.fillRect(x * scale, y * scale, scale, scale); - if (aa > 0.82 && scale >= 4) { - octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${(alpha * 0.18).toFixed(3)})`; - octx.fillRect(x * scale + 1, y * scale + 1, Math.max(1, scale - 2), Math.max(1, scale - 2)); - } - } - } - - ctx.save(); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = 'screen'; - if (info.angle) { - ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); - ctx.rotate(info.angle); - ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); - } else { - ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); - } - ctx.restore(); - } function spawnGroundStepParticles(item, time, tile) { if (Math.random() > 0.55) return; @@ -5563,11 +5642,11 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { function getSpriteCanvas(asset, side, localLights = null) { const lightsOn = areNightLightsActive(renderPhase); const dynamicLights = Array.isArray(localLights) && localLights.length > 0; - const lightSignature = dynamicLights ? localLights.map((l) => `${Math.round(l.x * 2) / 2},${Math.round(l.y * 2) / 2},${l.c || ''},${l.cursor ? 'cursor' : 'self'}`).join('|') : ''; + const lightSignature = dynamicLights ? localLights.map((l) => `${Math.round(l.x * 2) / 2},${Math.round(l.y * 2) / 2},${l.c || ''},${l.cursor ? 'cursor' : l.externalLight ? 'external' : 'self'},${Math.round((Number(l.intensity ?? 1) || 1) * 100)},${Math.round((Number(l.radiusCells ?? 0) || 0) * 10)}`).join('|') : ''; const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}:${visualSettings().enableLights === false ? 'depthoff' : 'depthon'}:${lightSignature}` : `day:unlit:${lightSignature}`; const w = assetWidth(asset); const h = assetHeight(asset); - const revision = asset.contentHash || asset.updatedAt || asset.createdAt || asset.pixels || asset.faces?.right || ''; + const revision = asset.contentHash || asset.blobId || asset.updatedAt || asset.createdAt || asset.pixels || asset.faces?.right || ''; const key = `${asset.id}:${revision}:${side}:${w}x${h}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; const skipCache = dynamicLights && localLights.some((l) => l.cursor); if (!skipCache && spriteCache.has(key)) { @@ -5938,33 +6017,129 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { 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)) { - const loadedSchema = Number(expanded.schema || 0); - // v16 reset local art because the palette code mapping and seed artwork were intentionally rebuilt. - // v17 expanded the default gallery. v18 added more nature-themed defaults. v19 added giant showcase objects. v20 added more animals, fish, and birds. v21 added human and artificial-object defaults. v22 widened small-sprite hitboxes and fixed night cursor depth light. v23 strengthens multi-light depth response and upgrades lower-quality seed art while preserving v16+ user state. v24 sharpens near-light depth reflection falloff and expands the outlined default gallery. v25 fixes gray local-light wash, brings the selection bubble closer to sprite tops, and adds large heritage/masterpiece default works. v26 removes the single-light gray bloom regression, smooths cursor-light falloff, and removes the unwanted blue cursor halo artifact. v27 reduces gray bloom from asset-mounted lights, fixes the clock-hand offset, temporarily enables delete-for-all works, and expands the default gallery again. - if (loadedSchema < 16) return seedState(); - return normalizeState(expanded); + if (parsed?.format === LOCAL_MANIFEST_FORMAT) { + const loadedSchema = Number(parsed.schema || 0); + if (loadedSchema < SAVE_SCHEMA) { + localStorage.removeItem(STORAGE_KEY); + resetPhase2CacheOnBoot = true; + return seedState(); + } + return stateFromLocalManifest(parsed); } + // v32 only accepts the local manifest. Older full/compact saves are intentionally discarded. + localStorage.removeItem(STORAGE_KEY); + resetPhase2CacheOnBoot = true; + return seedState(); } } catch (error) { - console.warn('Could not load local state.', error); + console.warn('Could not load local manifest.', error); + resetPhase2CacheOnBoot = true; } return seedState(); } + function stateFromLocalManifest(manifest) { + const seeded = seedState(); + const input = { + ...seeded, + schema: SAVE_SCHEMA, + authorName: manifest.authorName || seeded.authorName, + objectVotes: manifest.objectVotes || {}, + assetVotes: manifest.assetVotes || {}, + hiddenAssets: manifest.hiddenAssets || {}, + hiddenObjects: manifest.hiddenObjects || {}, + moderationReports: normalizeModerationReports(manifest.moderationReports || []), + guardrails: { ...PHASE5_GUARDRAILS, ...(manifest.guardrails || {}) }, + settings: { ...defaultVisualSettings(), ...(manifest.settings || {}) }, + account: normalizeAccount(manifest.account), + publishLog: Array.isArray(manifest.publishLog) ? manifest.publishLog.slice(-300) : [], + worldMode: manifest.worldMode === 'shared' ? 'shared' : 'local', + serverSync: sanitizeManifestServerSync(manifest.serverSync), + tombstones: manifest.tombstones || { assets: {}, objects: {} }, + deletedSeedAssetNames: Array.isArray(manifest.deletedSeedAssetNames) ? manifest.deletedSeedAssetNames : [] + }; + return normalizeState(input); + } + + function sanitizeManifestServerSync(serverSync = {}) { + return { + lastServerEventId: serverSync.lastServerEventId || null, + authority: { ...DEFAULT_SERVER_AUTHORITY, ...(serverSync.authority || {}) }, + clock: serverSync.clock && typeof serverSync.clock === 'object' ? { ...serverSync.clock } : { worldTimeMs: Date.now(), syncedAt: Date.now() }, + dynamicTargets: serverSync.dynamicTargets && typeof serverSync.dynamicTargets === 'object' ? { ...serverSync.dynamicTargets } : {}, + pendingObjectVisuals: {} + }; + } + + async function restoreStateFromIndexedDb() { + if (!Phase2Sync?.readCachedSnapshot || !Phase2Sync?.readCachedAssets) return false; + try { + if (resetPhase2CacheOnBoot && Phase2Sync?.clearCache) { + await Phase2Sync.clearCache(); + return false; + } + const snapshot = await Phase2Sync.readCachedSnapshot('local-main'); + const manifest = Array.isArray(snapshot?.manifest) ? snapshot.manifest : []; + const assetIds = manifest.map((asset) => asset?.id).filter(Boolean); + if (!assetIds.length) return false; + const cachedAssets = await Phase2Sync.readCachedAssets(assetIds); + if (!cachedAssets.length) return false; + const previousSelectedAssetId = selectedAssetId; + state = normalizeState({ + ...state, + schema: SAVE_SCHEMA, + assets: cachedAssets, + placed: Array.isArray(snapshot.placed) ? snapshot.placed : [], + dynamicSummons: Array.isArray(snapshot.dynamicSummons) ? snapshot.dynamicSummons : [], + hiddenObjects: snapshot.hiddenObjects || state.hiddenObjects || {} + }); + if (previousSelectedAssetId && state.assets.some((asset) => asset.id === previousSelectedAssetId)) selectedAssetId = previousSelectedAssetId; + else selectedAssetId = state.assets[0]?.id ?? null; + return true; + } catch (error) { + console.warn('Could not restore IndexedDB state.', error); + return false; + } + } + + function buildLocalManifest(target = state) { + const serverSync = sanitizeManifestServerSync(target.serverSync || {}); + return { + format: LOCAL_MANIFEST_FORMAT, + schema: SAVE_SCHEMA, + updatedAt: Date.now(), + snapshotWorldId: 'local-main', + authorName: target.authorName || 'Local Artist', + objectVotes: target.objectVotes || {}, + assetVotes: target.assetVotes || {}, + hiddenAssets: target.hiddenAssets || {}, + hiddenObjects: target.hiddenObjects || {}, + moderationReports: Array.isArray(target.moderationReports) ? target.moderationReports : [], + guardrails: target.guardrails || null, + settings: target.settings || null, + account: target.account || null, + publishLog: Array.isArray(target.publishLog) ? target.publishLog.slice(-300) : [], + worldMode: target.worldMode === 'shared' ? 'shared' : 'local', + serverSync, + tombstones: target.tombstones || { assets: {}, objects: {} }, + deletedSeedAssetNames: Array.isArray(target.deletedSeedAssetNames) ? target.deletedSeedAssetNames : [], + counts: { + assets: target.assets?.length || 0, + staticObjects: target.placed?.length || 0, + dynamicObjects: target.dynamicSummons?.length || 0 + } + }; + } + function saveState() { state.schema = SAVE_SCHEMA; ensureWorldProtectionState(); - canonicalizeAssetStorage(); if (state.worldMode !== 'shared') adoptLocalCollectionOwnership(state); state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) }; state.moderationReports = normalizeModerationReports(state.moderationReports || []); state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; - 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(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(buildLocalManifest(state))); + cachePhase2Snapshot(); updateRotationStats(); } @@ -5975,7 +6150,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { for (const asset of assets || []) { if (!asset?.id) continue; if (byId.has(asset.id)) continue; - const key = [asset.contentHash || '', asset.name || '', asset.category || '', asset.subtype || '', asset.width || asset.size || '', asset.height || asset.size || '', normalizeOwnerAccountId(asset.ownerAccountId)].join('|'); + const key = [asset.contentHash || '', asset.blobId || '', asset.name || '', asset.category || '', asset.subtype || '', asset.width || asset.size || '', asset.height || asset.size || '', normalizeOwnerAccountId(asset.ownerAccountId)].join('|'); if (asset.contentHash && exact.has(key)) continue; byId.set(asset.id, asset); if (asset.contentHash) exact.add(key); @@ -6042,13 +6217,12 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { hiddenObjects: input.hiddenObjects || {}, moderationReports: normalizeModerationReports(input.moderationReports || []), guardrails: { ...PHASE5_GUARDRAILS, ...(input.guardrails || {}) }, - eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], sync: input.sync || { lastEventId: null }, settings: { ...defaultVisualSettings(), ...(input.settings || {}) }, account: normalizeAccount(input.account), publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [], worldMode: input.worldMode === 'shared' ? 'shared' : 'local', - serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] }, + serverSync: input.serverSync || { lastServerEventId: null }, tombstones: input.tombstones || { assets: {}, objects: {} }, deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : [] }; @@ -6075,18 +6249,6 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { return target; } - function canonicalizeAssetStorage() { - let changed = false; - state.assets = (state.assets || []).map((asset) => { - const right = asset?.faces?.right || asset?.pixels; - const needsCanonical = Array.isArray(right) || Array.isArray(asset?.pixels) || Array.isArray(asset?.meta?.depthPixels); - if (!needsCanonical) return asset; - changed = true; - return normalizeAsset(asset); - }); - if (changed) rebuildWorldIndex(); - } - function normalizeAsset(asset) { const width = assetWidth(asset); const height = assetHeight(asset); @@ -6102,6 +6264,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { size, width, height, + blobId: asset.blobId || asset.bi || buildPixelBlob(right, width, height).id, pixels: encodePixels(right, width, height), faces: category === 'dynamic' ? { right: encodePixels(right, width, height), left: 'mirror' } : null, parentAssetId: asset.parentAssetId || null, @@ -6647,14 +6810,14 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { const applySeedQualityFilter = (gallery) => ({ ...gallery, assets: gallery.assets - .filter((asset) => !removedLowQualitySeedAssetIds.has(asset.id)) + .filter((asset) => DEFAULT_GALLERY_KEEP_ASSET_NAMES.has(asset.name) && !removedLowQualitySeedAssetIds.has(asset.id)) .map((asset) => ({ ...asset, author: 'Local Artist', ownerAccountId: '' })), placed: gallery.placed - .filter((object) => !removedLowQualitySeedAssetIds.has(object.assetId)) + .filter((object) => DEFAULT_GALLERY_KEEP_ASSET_NAMES.has(assetById.get(object.assetId)?.name || '') && !removedLowQualitySeedAssetIds.has(object.assetId)) .map(normalizeSeedPlacementTerrain) .map((object) => ({ ...object, ownerAccountId: '' })), dynamicSummons: gallery.dynamicSummons - .filter((object) => !removedLowQualitySeedAssetIds.has(object.assetId)) + .filter((object) => DEFAULT_GALLERY_KEEP_ASSET_NAMES.has(assetById.get(object.assetId)?.name || '') && !removedLowQualitySeedAssetIds.has(object.assetId)) .map((object) => ({ ...object, ownerAccountId: '' })) }); const whalePos = findNearestTerrain('water', 62, 58); @@ -6843,7 +7006,6 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { worldMode: 'local', serverSync: { lastServerEventId: null, - pendingCommands: [], authority: { ...DEFAULT_SERVER_AUTHORITY }, clock: { worldTimeMs: now, syncedAt: now, dayMs: DAY_MS }, dynamicTargets: {}, @@ -6866,6 +7028,8 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { const id = uid(); const right = alignPixelsToBottomRect(normalizePixels(pixels, w, h), w, h); const left = leftPixels ? alignPixelsToBottomRect(normalizePixels(leftPixels, w, h), w, h) : null; + const encodedRight = encodePixels(right, w, h); + const pixelBlob = buildPixelBlob(encodedRight, w, h); const asset = { id, name, @@ -6874,8 +7038,9 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { size, width: w, height: h, - pixels: encodePixels(right, w, h), - faces: category === 'dynamic' ? { right: encodePixels(right, w, h), left: left ? encodePixels(left, w, h) : 'mirror' } : null, + blobId: pixelBlob.id, + pixels: encodedRight, + faces: category === 'dynamic' ? { right: encodedRight, left: left ? encodePixels(left, w, h) : 'mirror' } : null, parentAssetId: null, originalAssetId: null, createdAt: Date.now(), @@ -6924,11 +7089,8 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { if (command) queueSharedCommand(command); 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; + state.sync.lastEventId = event.id || state.sync.lastEventId; } function sharedCommandFromLocalEvent(event) { @@ -7044,9 +7206,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { state.serverSync.lastServerEventId = event.serverEventId || state.serverSync.lastServerEventId; } - function cachePhase2State() { - if (!Phase2Sync?.cacheAssets) return; - Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); + function cachePhase2Snapshot() { if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) { Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error)); } @@ -7081,6 +7241,37 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { return tile.type !== 'water'; } + function buildPixelBlob(encodedPixels, width, height = width) { + const w = clampDimension(width, 16); + const h = clampDimension(height, w); + const payload = encodePixels(encodedPixels, w, h); + const codec = 'palette-index-v1'; + return { + id: computePixelBlobId(codec, w, h, payload), + codec, + width: w, + height: h, + payload + }; + } + + function computePixelBlobId(codec, width, height, payload) { + const normalizedCodec = codec || 'palette-index-v1'; + const w = clampDimension(width, 16); + const h = clampDimension(height, w); + const encoded = encodePixels(payload, w, h); + return `blob:${fnv1a(['pixel-blob-v1', normalizedCodec, w, h, encoded].join('|'))}`; + } + + function ensureAssetBlobId(asset) { + if (!asset) return asset; + const w = assetWidth(asset); + const h = assetHeight(asset); + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(w, h), w, h); + const blob = buildPixelBlob(right, w, h); + return { ...asset, blobId: asset.blobId || asset.bi || blob.id }; + } + function computeAssetContentHash(asset) { const payload = [ asset.category || '', @@ -7088,6 +7279,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { asset.size || '', asset.width || '', asset.height || '', + asset.blobId || '', asset.pixels || '', asset.faces?.right || '', asset.faces?.left || '', @@ -7267,6 +7459,16 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { return clamp(plateau * 0.72 + shoulder * 0.36 + hotspot * 0.92 + tail, 0, 1.76); } + function depthLightFloor(depth, sourceKind, response, hotspot, edge) { + if (!depth) return 0; + const facing = clamp(edge, 0, 1); + const responseFloor = clamp(Math.max(Number(response) || 0, (Number(hotspot) || 0) * 0.7), 0, 1.55); + const kindBase = sourceKind === 'self' ? 2.8 : sourceKind === 'cursor' ? 5.35 : 3.45; + const depthBase = depth > 0 ? (sourceKind === 'cursor' ? 1.22 : 1.0) : (sourceKind === 'cursor' ? 0.78 : 0.56); + const faceFloor = 0.24 + facing * 0.72 + (Number(hotspot) || 0) * 0.5; + return kindBase * depthBase * faceFloor * (0.32 + responseFloor * 0.68); + } + function shadeAssetPixelColor(hex, depth, x, y, width, phase, pixels = null, depths = null, lights = [], height = width) { const rgb = parseHex(hex); if (!rgb) return hex; @@ -7295,12 +7497,13 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { if (!lc) continue; const isAssetLight = !!light.assetLight; const isCursorLight = !!light.cursor; + const sourceKind = isAssetLight ? 'self' : isCursorLight ? 'cursor' : 'external'; const dist = Math.hypot((light.x + 0.5) - (x + 0.5), (light.y + 0.5) - (y + 0.5)); - const reach = isAssetLight ? Math.max(1.85, size * 0.24) : isCursorLight ? Math.max(2.6, size * 0.52) : Math.max(2.25, size * 0.46); + const reach = isAssetLight ? Math.max(1.85, size * 0.24) : isCursorLight ? Math.max(4.2, Number(light.radiusCells) || size * 0.78) : Math.max(2.25, Number(light.radiusCells) || size * 0.46); const t = 1 - clamp(dist / reach, 0, 1); - const sourceHotspot = Math.pow(clamp(1 - dist / Math.max(0.001, reach * 0.24), 0, 1), 2.8); + const sourceHotspot = Math.pow(clamp(1 - dist / Math.max(0.001, reach * (isCursorLight ? 0.42 : 0.24)), 0, 1), isCursorLight ? 1.75 : 2.8); const assetResponse = clamp(Math.pow(t, 1.45) * 0.52 + sourceHotspot * 1.18, 0, 1.58); - const cursorResponse = Math.pow(clamp(t, 0, 1), 1.58) * (0.12 + sourceHotspot * 0.42); + const cursorResponse = clamp(Math.pow(clamp(t, 0, 1), 0.92) * 0.72 + sourceHotspot * 1.28, 0, 1.85); const depthResponse = depth ? (isAssetLight ? assetResponse : isCursorLight ? cursorResponse : depthLocalLightResponse(dist, reach)) : (isAssetLight ? assetResponse : t); const response = depth ? depthResponse : t; if (response <= 0) continue; @@ -7308,31 +7511,32 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { const edge = getDepthLightFacing(depth, x, y, width, pixels, depths, light.x, light.y, height); const depthEdgePresence = isAssetLight ? (0.18 + Math.pow(Math.max(0, edge), 1.4) * 0.74 + sourceHotspot * 0.7) - : isCursorLight ? (0.02 + edge * 1.12 + sourceHotspot * 0.16) : (0.06 + edge * 1.24); + : isCursorLight ? (0.22 + edge * 1.18 + sourceHotspot * 0.58) : (0.06 + edge * 1.24); const edgePresence = depth ? depthEdgePresence : 1; const localDepthMultiplier = depth ? DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : 1.1; const strengthBase = depth - ? (isAssetLight ? (sourceHotspot * 0.34 + depthResponse * 0.045) : isCursorLight ? (sourceHotspot * 0.18 + depthResponse * 0.12) : (0.12 + depthResponse * 0.22)) + ? (isAssetLight ? (sourceHotspot * 0.34 + depthResponse * 0.045) : isCursorLight ? (sourceHotspot * 0.72 + depthResponse * 0.44) : (0.12 + depthResponse * 0.22)) : t * t; - const strength = strengthBase * (isAssetLight ? (10.5 + sourceHotspot * 18 + Math.abs(depth) * 1.2) : isCursorLight ? (2.1 + Math.abs(depth) * 1.15) : (4.2 + Math.abs(depth) * 2.6)) * edgePresence * sourceIntensity * localDepthMultiplier; - const mix = Math.min(depth ? (isAssetLight ? 0.055 : isCursorLight ? 0.18 : 0.48) : (isAssetLight ? 0.06 : isCursorLight ? 0.16 : 0.24), (0.008 + response * (depth ? (isAssetLight ? 0.012 : isCursorLight ? 0.038 : 0.075) : (isAssetLight ? 0.028 : isCursorLight ? 0.072 : 0.10))) * sourceIntensity * (depth ? (isAssetLight ? 0.7 : isCursorLight ? 0.85 : 1.35) : 1)); + const floorLift = depthLightFloor(depth, sourceKind, response, sourceHotspot, edge); + const strength = (strengthBase * (isAssetLight ? (10.5 + sourceHotspot * 18 + Math.abs(depth) * 1.2) : isCursorLight ? (6.4 + sourceHotspot * 10.5 + Math.abs(depth) * 2.6) : (4.2 + Math.abs(depth) * 2.6)) * edgePresence + floorLift * (isCursorLight ? 1.55 : 1)) * sourceIntensity * localDepthMultiplier * (isCursorLight && depth ? CURSOR_DEPTH_REFLECTION_MULTIPLIER : 1); + const mix = Math.min(depth ? (isAssetLight ? 0.055 : isCursorLight ? 0.30 : 0.48) : (isAssetLight ? 0.06 : isCursorLight ? 0.20 : 0.24), (0.008 + response * (depth ? (isAssetLight ? 0.012 : isCursorLight ? 0.072 : 0.075) : (isAssetLight ? 0.028 : isCursorLight ? 0.088 : 0.10))) * sourceIntensity * (depth ? (isAssetLight ? 0.7 : isCursorLight ? 1.45 : 1.35) : 1)); r = lerp(r, lc.r + strength, mix); g = lerp(g, lc.g + strength * 0.82, mix * 0.94); b = lerp(b, lc.b + strength * 0.74, mix * 0.9); - const colorWash = (depth ? (isAssetLight ? 0.004 : isCursorLight ? 0.012 : 0.045) : (isAssetLight ? 0.006 : isCursorLight ? 0.018 : 0.03)) * sourceIntensity * edgePresence * (depth ? (isAssetLight ? sourceHotspot : isCursorLight ? depthResponse : (0.26 + depthResponse * 0.22)) : t); + const colorWash = (depth ? (isAssetLight ? 0.004 : isCursorLight ? 0.034 : 0.045) : (isAssetLight ? 0.006 : isCursorLight ? 0.024 : 0.03)) * sourceIntensity * edgePresence * (depth ? (isAssetLight ? sourceHotspot : isCursorLight ? (depthResponse * 0.72 + sourceHotspot * 0.58) : (0.26 + depthResponse * 0.22)) : t); r += (lc.r - 152) * colorWash; g += (lc.g - 152) * colorWash * 0.96; b += (lc.b - 152) * colorWash * 0.92; - const tintBase = depth ? (isAssetLight ? sourceHotspot * 0.11 : isCursorLight ? depthResponse : (0.12 + depthResponse * 0.18)) : Math.pow(t, 1.25); - const tintPower = tintBase * sourceIntensity * (depth ? (isAssetLight ? 0.016 : isCursorLight ? 0.045 : 0.12) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : (isAssetLight ? 0.012 : isCursorLight ? 0.018 : 0.03)) * (depth ? edgePresence : 1); + const tintBase = depth ? (isAssetLight ? sourceHotspot * 0.11 : isCursorLight ? (depthResponse * 1.12 + sourceHotspot * 0.78) : (0.12 + depthResponse * 0.18)) : Math.pow(t, 1.25); + const tintPower = tintBase * sourceIntensity * (depth ? (isAssetLight ? 0.016 : isCursorLight ? 0.078 : 0.12) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : (isAssetLight ? 0.012 : isCursorLight ? 0.024 : 0.03)) * (depth ? edgePresence : 1); localTintR += lc.r * tintPower; localTintG += lc.g * tintPower; localTintB += lc.b * tintPower; localTintWeight += tintPower; - const liftBase = depth ? (isAssetLight ? (sourceHotspot * 0.42 + Math.pow(t, 2.2) * 0.035) : isCursorLight ? depthResponse : (0.12 + depthResponse * 0.16)) : Math.pow(t, 1.9); - localLift += liftBase * sourceIntensity * (depth ? (isAssetLight ? 0.85 : isCursorLight ? 1.05 : 2.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : 3.6) * (depth ? edgePresence : 1); + const liftBase = depth ? (isAssetLight ? (sourceHotspot * 0.42 + Math.pow(t, 2.2) * 0.035) : isCursorLight ? (depthResponse * 1.18 + sourceHotspot * 0.78) : (0.12 + depthResponse * 0.16)) : Math.pow(t, 1.9); + localLift += (liftBase * (depth ? (isAssetLight ? 0.85 : isCursorLight ? 2.35 : 2.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : 3.6) * (depth ? edgePresence : 1) + floorLift * (isAssetLight ? 0.16 : isCursorLight ? 0.36 : 0.24)) * sourceIntensity; } } @@ -7343,9 +7547,9 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { b = clamp(quantized.b, 0, 255); if (localTintWeight > 0) { const inv = 1 / localTintWeight; - const tintMix = clamp(localTintWeight / (depth ? 6.2 : 9.0), 0, depth ? 0.34 : 0.16); - const lift = clamp(localLift, 0, depth ? 11 : 12); - const chromaRestore = clamp(localTintWeight / (depth ? 1.15 : 8.8), 0, depth ? 0.9 : 0.16); + const tintMix = clamp(localTintWeight / (depth ? 5.1 : 9.0), 0, depth ? 0.42 : 0.16); + const lift = clamp(localLift, 0, depth ? 22 : 12); + const chromaRestore = clamp(localTintWeight / (depth ? 0.95 : 8.8), 0, depth ? 0.96 : 0.16); r = lerp(clamp(r + lift, 0, 255), litBeforeQuantization.r, chromaRestore); g = lerp(clamp(g + lift * 0.88, 0, 255), litBeforeQuantization.g, chromaRestore); b = lerp(clamp(b + lift * 0.8, 0, 255), litBeforeQuantization.b, chromaRestore); @@ -7487,22 +7691,26 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) { if (!light) continue; const d = Math.hypot(light.x - x, light.y - y); const isCursor = !!light.cursor; - const lightReach = isCursor ? Math.max(2.6, Math.max(width, height) * 0.52) : reach; - const distanceResponse = isCursor ? Math.pow(clamp(1 - d / lightReach, 0, 1), 1.55) : depthLocalLightResponse(d, lightReach); + const isExternal = !!light.externalLight; + const lightReach = isCursor ? Math.max(2.6, Math.max(width, height) * 0.52) : isExternal ? Math.max(2.25, Number(light.radiusCells) || reach) : reach; + const nearSource = Math.pow(clamp(1 - d / Math.max(1.2, lightReach * (isCursor ? 0.34 : 0.18)), 0, 1), isCursor ? 1.55 : 2.3); + const distanceResponse = isCursor + ? clamp(Math.pow(clamp(1 - d / lightReach, 0, 1), 0.82) * 0.72 + nearSource * 0.72, 0, 1.7) + : depthLocalLightResponse(d, lightReach); if (distanceResponse <= 0) continue; const facing = getDepthLightFacing(depth, x, y, width, pixels, depths, light.x, light.y, height); - if (!facing) continue; - const sourceIntensity = clamp(Number(light.intensity ?? 1) || 1, 0.25, isCursor ? 1.25 : 1.85); - const nearSource = Math.pow(clamp(1 - d / Math.max(1.2, lightReach * 0.18), 0, 1), 2.3); + if (!isCursor && !facing) continue; + const facingResponse = isCursor ? Math.max(facing, nearSource * 0.42, 0.14) : facing; + const sourceIntensity = clamp(Number(light.intensity ?? 1) || 1, 0.25, isCursor ? 2.45 : 1.85); const plateau = isCursor ? distanceResponse : 0.74 + distanceResponse * 0.34; - const edgeFocus = isCursor ? (0.1 + facing * 1.18) : (0.4 + facing * 0.95); + const edgeFocus = isCursor ? (0.44 + facingResponse * 1.26) : (0.4 + facing * 0.95); const edgeStrength = outerEdge ? 1.08 : 0.94; - const cornerBoost = 1 + cornerStrength * (isCursor ? 0.36 : 0.9 + facing * 0.38); - const sourceBoost = 1 + nearSource * (isCursor ? 0.46 : 1.12); - const cursorClamp = isCursor ? 0.38 : 1; - total += plateau * edgeFocus * edgeStrength * cornerBoost * sourceBoost * sourceIntensity * (depth > 0 ? 8.2 : 6.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER * cursorClamp; + const cornerBoost = 1 + cornerStrength * (isCursor ? 0.58 + nearSource * 0.28 : 0.9 + facing * 0.38); + const sourceBoost = 1 + nearSource * (isCursor ? 1.35 : 1.12); + const cursorAmplifier = isCursor ? CURSOR_DEPTH_REFLECTION_MULTIPLIER : 1; + total += plateau * edgeFocus * edgeStrength * cornerBoost * sourceBoost * sourceIntensity * (depth > 0 ? 8.2 : 6.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER * cursorAmplifier; } - return clamp(total, 0, depth > 0 ? 172 : 126); + return clamp(total, 0, depth > 0 ? 230 : 164); } function resizePixels(source, oldSize, newSize) { diff --git a/js/phase2-sync.js b/js/phase2-sync.js index 8f7b269..697e23a 100644 --- a/js/phase2-sync.js +++ b/js/phase2-sync.js @@ -2,23 +2,18 @@ 'use strict'; const root = window.PixelIslandModules ||= {}; - const FORMAT = 'pixel-island-phase2-compact-v2'; - const FORMAT_V1 = 'pixel-island-phase2-compact-v1'; const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1'; const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2'; const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1'; - const EVENT_LOG_LIMIT = 300; const DB_NAME = 'pixel-island-phase2-cache'; - const DB_VERSION = 1; + const DB_VERSION = 6; const ASSET_STORE = 'assets'; const SNAPSHOT_STORE = 'snapshots'; + const PIXEL_BLOB_STORE = 'pixelBlobs'; + const OUTBOX_STORE = 'outbox'; const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const PACK6_CHARS = `.${COLOR_CODES}`; - function isCompactState(value) { - return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets)); - } - function isAssetBundle(value) { return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets)); } @@ -292,6 +287,7 @@ return { id: asset.id, h: asset.contentHash || asset.hash || null, + bi: asset.blobId || asset.bi || null, n: asset.name || 'Untitled', c: category, t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), @@ -311,13 +307,58 @@ }; } - function unpackAsset(packed, assetById = null) { + + function packAssetMetadata(asset, assetMap = null) { + const packed = packAsset(asset, assetMap); + delete packed.p; + return packed; + } + + function packPixelBlob(input) { + if (!input) return null; + const width = assetWidth(input); + const height = assetHeight(input); + const payload = normalizeEncodedPlane(input.payload || input.faces?.right || input.pixels || '', width, '.', height); + const id = input.blobId || input.bi || ((input.codec || input.payload) ? input.id : null); + if (!id) return null; + return { + id, + co: input.codec || 'palette-index-v1', + w: width, + ht: height, + p: cropPlane(payload, width, '.', { bitPack: true }, height), + ca: input.createdAt || Date.now(), + ua: input.updatedAt || input.createdAt || Date.now() + }; + } + + function unpackPixelBlob(packed) { + if (!packed || !packed.id) return null; + const width = Math.max(1, Number(packed.w || packed.width) || 16); + const height = Math.max(1, Number(packed.ht || packed.height || width) || width); + return { + id: packed.id, + codec: packed.co || packed.codec || 'palette-index-v1', + width, + height, + payload: expandPlane(packed.p, width, '.', null, height), + createdAt: packed.ca || null, + updatedAt: packed.ua || packed.ca || null + }; + } + + function unpackAsset(packed, assetById = null, pixelBlobById = null) { if (!packed || !packed.id) return null; const size = Math.max(1, Number(packed.s || packed.size) || 16); const width = Math.max(1, Number(packed.w || packed.width || size) || size); const height = Math.max(1, Number(packed.ht || packed.height || size) || size); const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static'; - const pixels = expandPlane(packed.p, width, '.', null, height); + const blobId = packed.bi || packed.blobId || null; + const pixelBlob = blobId && pixelBlobById + ? (typeof pixelBlobById.get === 'function' ? pixelBlobById.get(blobId) : pixelBlobById[blobId]) + : null; + const blobPayload = typeof pixelBlob?.payload === 'string' ? pixelBlob.payload : null; + const pixels = blobPayload ? normalizeEncodedPlane(blobPayload, width, '.', height) : expandPlane(packed.p, width, '.', null, height); if (pixels == null) return null; const metaPacked = packed.m || {}; const lightPixels = Array.isArray(metaPacked.l) @@ -347,6 +388,7 @@ size, width, height, + blobId, pixels, faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null, parentAssetId: packed.pa || null, @@ -361,8 +403,8 @@ }; } - function unpackAssets(rows) { - return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean); + function unpackAssets(rows, pixelBlobById = null) { + return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row, null, pixelBlobById)).filter(Boolean); } function packPlacement(item) { @@ -397,75 +439,8 @@ return new Map((assets || []).map((asset) => [asset.id, asset])); } - function compactState(state) { - const assets = Array.isArray(state.assets) ? state.assets : []; - const map = assetMapFor(assets); - return { - schema: 4, - format: FORMAT, - authorName: state.authorName || 'Local Artist', - assets: assets.map((asset) => packAsset(asset, map)), - 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 || {}, - moderationReports: Array.isArray(state.moderationReports) ? state.moderationReports : [], - guardrails: state.guardrails || null, - settings: state.settings || null, - account: state.account || null, - publishLog: Array.isArray(state.publishLog) ? state.publishLog.slice(-300) : [], - eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [], - sync: state.sync || { lastEventId: null }, - worldMode: state.worldMode === 'shared' ? 'shared' : 'local', - serverSync: state.serverSync || { lastServerEventId: null, pendingCommands: [] }, - tombstones: state.tombstones || { assets: {}, objects: {} }, - deletedSeedAssetNames: Array.isArray(state.deletedSeedAssetNames) ? state.deletedSeedAssetNames : [] - }; - } - - function expandState(input) { - if (!isCompactState(input)) return input; - return { - schema: input.schema || 4, - authorName: input.authorName || 'Local Artist', - assets: unpackAssets(input.assets), - placed: (input.placed || []).map(unpackPlacement), - dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic), - objectVotes: input.objectVotes || {}, - assetVotes: input.assetVotes || {}, - hiddenAssets: input.hiddenAssets || {}, - hiddenObjects: input.hiddenObjects || {}, - moderationReports: Array.isArray(input.moderationReports) ? input.moderationReports : [], - guardrails: input.guardrails || null, - settings: input.settings || null, - account: input.account || null, - publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [], - eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [], - sync: input.sync || { lastEventId: null }, - worldMode: input.worldMode === 'shared' ? 'shared' : 'local', - serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] }, - tombstones: input.tombstones || { assets: {}, objects: {} }, - deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : [] - }; - } - - function compactSizeReport(state) { - const full = JSON.stringify({ ...state, schema: 4 }); - 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, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null })); + return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, bi: asset.blobId || null, s: asset.size, w: asset.width || null, ht: asset.height || null, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null })); } function makeSnapshot(state, worldId = 'local-main') { @@ -574,8 +549,17 @@ const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; + const tx = request.transaction; if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' }); if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' }); + if (!db.objectStoreNames.contains(PIXEL_BLOB_STORE)) db.createObjectStore(PIXEL_BLOB_STORE, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(OUTBOX_STORE)) db.createObjectStore(OUTBOX_STORE, { keyPath: 'id' }); + if (request.oldVersion && request.oldVersion < DB_VERSION) { + if (db.objectStoreNames.contains(ASSET_STORE)) tx.objectStore(ASSET_STORE).clear(); + if (db.objectStoreNames.contains(SNAPSHOT_STORE)) tx.objectStore(SNAPSHOT_STORE).clear(); + if (db.objectStoreNames.contains(PIXEL_BLOB_STORE)) tx.objectStore(PIXEL_BLOB_STORE).clear(); + if (db.objectStoreNames.contains(OUTBOX_STORE)) tx.objectStore(OUTBOX_STORE).clear(); + } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); @@ -587,24 +571,62 @@ 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)); + const assetStore = tx.objectStore(ASSET_STORE); + const map = assetMapFor(assets); + for (const asset of assets) assetStore.put(packAssetMetadata(asset, map)); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); db.close(); } - async function readCachedAssets(assetIds) { - const ids = Array.from(assetIds || []); + async function cachePixelBlobs(pixelBlobs) { + if (!Array.isArray(pixelBlobs) || !pixelBlobs.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(PIXEL_BLOB_STORE, 'readwrite'); + const store = tx.objectStore(PIXEL_BLOB_STORE); + for (const pixelBlob of pixelBlobs) { + const packed = packPixelBlob(pixelBlob); + if (packed) store.put(packed); + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + async function readCachedPixelBlobs(blobIds) { + const ids = Array.from(blobIds || []).filter(Boolean); 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); + const request = db.transaction(PIXEL_BLOB_STORE, 'readonly').objectStore(PIXEL_BLOB_STORE).get(id); request.onsuccess = () => resolve(request.result || null); request.onerror = () => resolve(null); }))); db.close(); - return unpackAssets(rows.filter(Boolean)); + return rows.map(unpackPixelBlob).filter(Boolean); + } + + async function readCachedAssets(assetIds) { + const ids = Array.from(assetIds || []); + if (!ids.length) return []; + const db = await openDb(); + const assetRows = 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); + }))); + const blobIds = assetRows.map((row) => row?.bi || row?.blobId).filter(Boolean); + const blobRows = await Promise.all(blobIds.map((id) => new Promise((resolve) => { + const request = db.transaction(PIXEL_BLOB_STORE, 'readonly').objectStore(PIXEL_BLOB_STORE).get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => resolve(null); + }))); + db.close(); + const blobById = new Map(blobRows.map(unpackPixelBlob).filter(Boolean).map((blob) => [blob.id, blob])); + return unpackAssets(assetRows.filter(Boolean), blobById); } async function cacheSnapshot(snapshot) { @@ -619,14 +641,98 @@ db.close(); } + + async function readCachedSnapshot(worldId = 'local-main') { + const db = await openDb(); + const row = await new Promise((resolve) => { + const request = db.transaction(SNAPSHOT_STORE, 'readonly').objectStore(SNAPSHOT_STORE).get(worldId); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => resolve(null); + }); + db.close(); + if (!row) return null; + return { + ...row, + placed: Array.isArray(row.placed) ? row.placed.map(unpackPlacement) : [], + dynamicSummons: Array.isArray(row.dynamicSummons) ? row.dynamicSummons.map(unpackDynamic) : [], + hiddenObjects: row.hiddenObjects || {} + }; + } + + async function deleteCachedAssets(assetIds, blobIds = []) { + const assets = Array.from(assetIds || []).filter(Boolean); + const blobs = Array.from(blobIds || []).filter(Boolean); + if (!assets.length && !blobs.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction([ASSET_STORE, PIXEL_BLOB_STORE], 'readwrite'); + const assetStore = tx.objectStore(ASSET_STORE); + const blobStore = tx.objectStore(PIXEL_BLOB_STORE); + for (const id of assets) assetStore.delete(id); + for (const id of blobs) blobStore.delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + async function cacheOutboxCommand(command) { + if (!command?.id) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, 'readwrite'); + tx.objectStore(OUTBOX_STORE).put({ ...command, queuedAt: command.queuedAt || Date.now() }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + async function readOutboxCommands(limit = 300) { + const db = await openDb(); + const rows = await new Promise((resolve) => { + const request = db.transaction(OUTBOX_STORE, 'readonly').objectStore(OUTBOX_STORE).getAll(); + request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []); + request.onerror = () => resolve([]); + }); + db.close(); + return rows + .sort((a, b) => Number(a.createdAt || a.queuedAt || 0) - Number(b.createdAt || b.queuedAt || 0)) + .slice(-Math.max(1, Number(limit) || 300)); + } + + async function deleteOutboxCommands(commandIds) { + const ids = Array.from(commandIds || []).filter(Boolean); + if (!ids.length) return; + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(OUTBOX_STORE, 'readwrite'); + const store = tx.objectStore(OUTBOX_STORE); + for (const id of ids) store.delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + + async function clearCache() { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction([ASSET_STORE, PIXEL_BLOB_STORE, SNAPSHOT_STORE, OUTBOX_STORE], 'readwrite'); + tx.objectStore(ASSET_STORE).clear(); + tx.objectStore(PIXEL_BLOB_STORE).clear(); + tx.objectStore(SNAPSHOT_STORE).clear(); + tx.objectStore(OUTBOX_STORE).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + db.close(); + } + root.Phase2Sync = { - FORMAT, - FORMAT_V1, SNAPSHOT_FORMAT, ASSET_BUNDLE_FORMAT, ASSET_BUNDLE_FORMAT_V1, - EVENT_LOG_LIMIT, - isCompactState, isAssetBundle, rleEncode, rleDecode, @@ -637,15 +743,15 @@ cropPlane, expandPlane, packAsset, + packAssetMetadata, + packPixelBlob, + unpackPixelBlob, unpackAsset, unpackAssets, packPlacement, unpackPlacement, packDynamic, unpackDynamic, - compactState, - expandState, - compactSizeReport, assetManifest, makeSnapshot, makeAssetBundle, @@ -659,7 +765,15 @@ deleteAssetOnly, applyEvent, cacheAssets, + cachePixelBlobs, + readCachedPixelBlobs, readCachedAssets, - cacheSnapshot + cacheSnapshot, + readCachedSnapshot, + deleteCachedAssets, + cacheOutboxCommand, + readOutboxCommands, + deleteOutboxCommands, + clearCache }; })(); diff --git a/styles.css b/styles.css index 9fe5128..9409c6f 100644 --- a/styles.css +++ b/styles.css @@ -2838,3 +2838,9 @@ body, button, input, select, textarea { font-size: 15px; } .collectionSearch { flex-basis:104px !important; width:104px !important; } .studioDrawer { z-index: var(--drawer-z) !important; } } + +/* Keep Collection list redraws from letting scroll anchoring snap the drawer to the top. */ +.drawerBody, +.assetList { + overflow-anchor: none; +}