This commit is contained in:
33333-33333 2026-06-03 21:29:49 +09:00
commit 92abfa3c89
3 changed files with 644 additions and 316 deletions

658
app.js

File diff suppressed because it is too large Load diff

View file

@ -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
};
})();

View file

@ -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;
}