update
This commit is contained in:
parent
67a7477242
commit
a05c031074
7 changed files with 1781 additions and 385 deletions
|
|
@ -2,21 +2,134 @@
|
|||
'use strict';
|
||||
|
||||
const root = window.PixelIslandModules ||= {};
|
||||
const FORMAT = 'pixel-island-phase2-compact-v1';
|
||||
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-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 ASSET_STORE = 'assets';
|
||||
const SNAPSHOT_STORE = 'snapshots';
|
||||
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const PACK6_CHARS = `.${COLOR_CODES}`;
|
||||
|
||||
function isCompactState(value) {
|
||||
return Boolean(value && value.format === FORMAT && Array.isArray(value.assets));
|
||||
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 && Array.isArray(value.assets));
|
||||
return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets));
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
if (!bytes || !bytes.length) return '';
|
||||
if (typeof btoa === 'function') {
|
||||
let binary = '';
|
||||
const chunk = 8192;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
binary += String.fromCharCode(...bytes.slice(i, i + chunk));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
|
||||
return '';
|
||||
}
|
||||
|
||||
function base64ToBytes(value) {
|
||||
const text = String(value || '');
|
||||
if (!text) return [];
|
||||
if (typeof atob === 'function') {
|
||||
const binary = atob(text);
|
||||
const out = new Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255;
|
||||
return out;
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64'));
|
||||
return [];
|
||||
}
|
||||
|
||||
function pack6Index(ch) {
|
||||
const index = PACK6_CHARS.indexOf(ch || '.');
|
||||
return index >= 0 && index < 64 ? index : 0;
|
||||
}
|
||||
|
||||
function unpack6Index(value) {
|
||||
return PACK6_CHARS[value] || '.';
|
||||
}
|
||||
|
||||
function canPack6(text) {
|
||||
for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function pack6Text(input) {
|
||||
const text = String(input || '');
|
||||
if (!text) return '';
|
||||
let buffer = 0;
|
||||
let bitCount = 0;
|
||||
const bytes = [];
|
||||
for (const ch of text) {
|
||||
buffer = (buffer << 6) | pack6Index(ch);
|
||||
bitCount += 6;
|
||||
while (bitCount >= 8) {
|
||||
bitCount -= 8;
|
||||
bytes.push((buffer >> bitCount) & 255);
|
||||
buffer &= (1 << bitCount) - 1;
|
||||
}
|
||||
}
|
||||
if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function unpack6Text(input, length) {
|
||||
const target = Math.max(0, Number(length) || 0);
|
||||
if (!target) return '';
|
||||
const bytes = base64ToBytes(input);
|
||||
let buffer = 0;
|
||||
let bitCount = 0;
|
||||
let out = '';
|
||||
for (const byte of bytes) {
|
||||
buffer = (buffer << 8) | (byte & 255);
|
||||
bitCount += 8;
|
||||
while (bitCount >= 6 && out.length < target) {
|
||||
bitCount -= 6;
|
||||
out += unpack6Index((buffer >> bitCount) & 63);
|
||||
buffer &= (1 << bitCount) - 1;
|
||||
}
|
||||
if (out.length >= target) break;
|
||||
}
|
||||
return (out + '.'.repeat(target)).slice(0, target);
|
||||
}
|
||||
|
||||
function packBitMask(mask) {
|
||||
const bits = Array.from(mask || [], Boolean);
|
||||
if (!bits.length) return '';
|
||||
const bytes = [];
|
||||
let current = 0;
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
current = (current << 1) | (bits[i] ? 1 : 0);
|
||||
if (i % 8 === 7) {
|
||||
bytes.push(current & 255);
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
const rest = bits.length % 8;
|
||||
if (rest) bytes.push((current << (8 - rest)) & 255);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function unpackBitMask(input, length) {
|
||||
const target = Math.max(0, Number(length) || 0);
|
||||
const bytes = base64ToBytes(input);
|
||||
const out = [];
|
||||
for (const byte of bytes) {
|
||||
for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1));
|
||||
if (out.length >= target) break;
|
||||
}
|
||||
while (out.length < target) out.push(false);
|
||||
return out;
|
||||
}
|
||||
|
||||
function rleEncode(input) {
|
||||
|
|
@ -61,7 +174,17 @@
|
|||
return (source + emptyChar.repeat(total)).slice(0, total);
|
||||
}
|
||||
|
||||
function cropPlane(encoded, size, emptyChar = '.') {
|
||||
function chooseSmallest(candidates) {
|
||||
let best = candidates[0];
|
||||
let bestSize = JSON.stringify(best).length;
|
||||
for (const candidate of candidates.slice(1)) {
|
||||
const size = JSON.stringify(candidate).length;
|
||||
if (size < bestSize) { best = candidate; bestSize = size; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function cropPlane(encoded, size, emptyChar = '.', options = {}) {
|
||||
const text = normalizeEncodedPlane(encoded, size, emptyChar);
|
||||
let minX = size;
|
||||
let minY = size;
|
||||
|
|
@ -81,42 +204,55 @@
|
|||
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 };
|
||||
for (let y = minY; y <= maxY; y++) cropped += text.slice(y * size + minX, y * size + minX + w);
|
||||
const candidates = [
|
||||
{ b: [minX, minY, w, h], e: 'raw', v: cropped },
|
||||
{ b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) }
|
||||
];
|
||||
if (options.bitPack && canPack6(cropped)) candidates.push({ b: [minX, minY, w, h], e: 'bp6', v: pack6Text(cropped), n: cropped.length });
|
||||
return chooseSmallest(candidates);
|
||||
}
|
||||
|
||||
function expandPlane(packed, size, emptyChar = '.') {
|
||||
function expandPlane(packed, size, emptyChar = '.', baseEncoded = null) {
|
||||
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 || '');
|
||||
let value = '';
|
||||
if (packed.e === 'rle') value = rleDecode(packed.v);
|
||||
else if (packed.e === 'bp6') value = unpack6Text(packed.v, packed.n || (w * h));
|
||||
else value = 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;
|
||||
}
|
||||
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) {
|
||||
function planeForAsset(asset) {
|
||||
const size = Math.max(1, Number(asset?.size) || 16);
|
||||
return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', size, '.');
|
||||
}
|
||||
|
||||
function selectPixelPlanePack(asset, assetMap) {
|
||||
const size = Math.max(1, Number(asset.size) || 16);
|
||||
const right = planeForAsset(asset);
|
||||
return cropPlane(right, size, '.', { bitPack: true });
|
||||
}
|
||||
|
||||
function packAsset(asset, assetMap = null) {
|
||||
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 particles = Array.isArray(asset.meta?.particlePixels)
|
||||
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2])
|
||||
? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2])
|
||||
: [];
|
||||
const meta = {};
|
||||
if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.');
|
||||
|
|
@ -124,6 +260,10 @@
|
|||
if (particles.length) meta.pt = particles;
|
||||
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];
|
||||
if (asset.meta?.particleConfig?.enabled && !particles.length) {
|
||||
const pc = asset.meta.particleConfig;
|
||||
meta.pc = [pc.c || '', pc.dir || 'up', null];
|
||||
}
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
|
|
@ -132,7 +272,7 @@
|
|||
c: category,
|
||||
t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
|
||||
s: size,
|
||||
p: cropPlane(right, size, '.'),
|
||||
p: selectPixelPlanePack(asset, assetMap),
|
||||
f: category === 'dynamic' ? { l: 'mirror' } : null,
|
||||
pa: asset.parentAssetId || null,
|
||||
oa: asset.originalAssetId || null,
|
||||
|
|
@ -143,24 +283,29 @@
|
|||
};
|
||||
}
|
||||
|
||||
function unpackAsset(packed) {
|
||||
function unpackAsset(packed, assetById = null) {
|
||||
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, '.');
|
||||
if (pixels == null) return null;
|
||||
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 particlePixels = Array.isArray(metaPacked.pt)
|
||||
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' }))
|
||||
? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a', dir: p[3] || 'up' }))
|
||||
: [];
|
||||
const particleConfig = Array.isArray(metaPacked.pc)
|
||||
? { enabled: true, c: metaPacked.pc[0] || 'a', dir: metaPacked.pc[1] || 'up', range: Array.isArray(metaPacked.pc[2]) ? { x: metaPacked.pc[2][0] || 0, y: metaPacked.pc[2][1] || 0, w: metaPacked.pc[2][2] || 0, h: metaPacked.pc[2][3] || 0 } : null }
|
||||
: null;
|
||||
const meta = {
|
||||
hasLight: lightPixels.length > 0,
|
||||
lightPixels,
|
||||
lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null,
|
||||
hasParticles: particlePixels.length > 0,
|
||||
hasParticles: Boolean(particleConfig) || particlePixels.length > 0,
|
||||
particlePixels,
|
||||
particleConfig,
|
||||
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
|
||||
};
|
||||
|
|
@ -182,6 +327,10 @@
|
|||
};
|
||||
}
|
||||
|
||||
function unpackAssets(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean);
|
||||
}
|
||||
|
||||
function packPlacement(item) {
|
||||
const meta = {};
|
||||
if (item.publishedAt) meta.pu = item.publishedAt;
|
||||
|
|
@ -208,12 +357,18 @@
|
|||
return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' };
|
||||
}
|
||||
|
||||
function assetMapFor(assets) {
|
||||
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: 3,
|
||||
schema: 4,
|
||||
format: FORMAT,
|
||||
authorName: state.authorName || 'Local Artist',
|
||||
assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [],
|
||||
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 || {},
|
||||
|
|
@ -233,9 +388,9 @@
|
|||
function expandState(input) {
|
||||
if (!isCompactState(input)) return input;
|
||||
return {
|
||||
schema: 3,
|
||||
schema: input.schema || 4,
|
||||
authorName: input.authorName || 'Local Artist',
|
||||
assets: input.assets.map(unpackAsset).filter(Boolean),
|
||||
assets: unpackAssets(input.assets),
|
||||
placed: (input.placed || []).map(unpackPlacement),
|
||||
dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic),
|
||||
objectVotes: input.objectVotes || {},
|
||||
|
|
@ -253,7 +408,7 @@
|
|||
}
|
||||
|
||||
function compactSizeReport(state) {
|
||||
const full = JSON.stringify({ ...state, schema: 3 });
|
||||
const full = JSON.stringify({ ...state, schema: 4 });
|
||||
const compact = JSON.stringify(compactState(state));
|
||||
return {
|
||||
fullBytes: full.length,
|
||||
|
|
@ -266,12 +421,12 @@
|
|||
}
|
||||
|
||||
function assetManifest(state) {
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype }));
|
||||
return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null }));
|
||||
}
|
||||
|
||||
function makeSnapshot(state, worldId = 'local-main') {
|
||||
return {
|
||||
schema: 3,
|
||||
schema: 4,
|
||||
format: SNAPSHOT_FORMAT,
|
||||
worldId,
|
||||
createdAt: Date.now(),
|
||||
|
|
@ -284,13 +439,14 @@
|
|||
|
||||
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 };
|
||||
const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id));
|
||||
const map = assetMapFor(assets);
|
||||
return { schema: 4, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets: assets.map((asset) => packAsset(asset, map)) };
|
||||
}
|
||||
|
||||
function unpackAssetBundle(bundle) {
|
||||
if (!isAssetBundle(bundle)) return [];
|
||||
return bundle.assets.map(unpackAsset).filter(Boolean);
|
||||
return unpackAssets(bundle.assets);
|
||||
}
|
||||
|
||||
function findMissingAssetIds(snapshot, knownAssetIds) {
|
||||
|
|
@ -306,6 +462,10 @@
|
|||
return makeEvent('asset.upsert', { asset: packAsset(asset) });
|
||||
}
|
||||
|
||||
function createAssetDeleteEvent(assetId) {
|
||||
return makeEvent('asset.delete', { assetId });
|
||||
}
|
||||
|
||||
function createObjectUpsertEvent(kind, object) {
|
||||
return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) });
|
||||
}
|
||||
|
|
@ -314,15 +474,34 @@
|
|||
return makeEvent('object.delete', { kind, objectId });
|
||||
}
|
||||
|
||||
function deleteAssetOnly(state, assetId) {
|
||||
if (!state || !assetId) return state;
|
||||
const removedObjectIds = new Set();
|
||||
for (const item of state.placed || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
|
||||
for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removedObjectIds.add(item.id);
|
||||
state.assets = (state.assets || []).filter((asset) => asset.id !== assetId);
|
||||
state.placed = (state.placed || []).filter((item) => item.assetId !== assetId);
|
||||
state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId);
|
||||
delete state.assetVotes?.[assetId];
|
||||
delete state.hiddenAssets?.[assetId];
|
||||
for (const id of removedObjectIds) delete state.objectVotes?.[id], delete state.hiddenObjects?.[id];
|
||||
state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId));
|
||||
return state;
|
||||
}
|
||||
|
||||
function applyEvent(state, event) {
|
||||
if (!state || !event) return state;
|
||||
if (event.type === 'asset.upsert' && event.asset) {
|
||||
const asset = unpackAsset(event.asset);
|
||||
const byId = assetMapFor(state.assets || []);
|
||||
const asset = unpackAsset(event.asset, byId);
|
||||
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 === 'asset.delete' && event.assetId) {
|
||||
deleteAssetOnly(state, event.assetId);
|
||||
}
|
||||
if (event.type === 'object.upsert') {
|
||||
if (event.kind === 'dynamic') {
|
||||
const object = unpackDynamic(event.object);
|
||||
|
|
@ -381,7 +560,7 @@
|
|||
request.onerror = () => resolve(null);
|
||||
})));
|
||||
db.close();
|
||||
return rows.filter(Boolean).map(unpackAsset).filter(Boolean);
|
||||
return unpackAssets(rows.filter(Boolean));
|
||||
}
|
||||
|
||||
async function cacheSnapshot(snapshot) {
|
||||
|
|
@ -398,17 +577,24 @@
|
|||
|
||||
root.Phase2Sync = {
|
||||
FORMAT,
|
||||
FORMAT_V1,
|
||||
SNAPSHOT_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT,
|
||||
ASSET_BUNDLE_FORMAT_V1,
|
||||
EVENT_LOG_LIMIT,
|
||||
isCompactState,
|
||||
isAssetBundle,
|
||||
rleEncode,
|
||||
rleDecode,
|
||||
pack6Text,
|
||||
unpack6Text,
|
||||
packBitMask,
|
||||
unpackBitMask,
|
||||
cropPlane,
|
||||
expandPlane,
|
||||
packAsset,
|
||||
unpackAsset,
|
||||
unpackAssets,
|
||||
compactState,
|
||||
expandState,
|
||||
compactSizeReport,
|
||||
|
|
@ -419,8 +605,10 @@
|
|||
findMissingAssetIds,
|
||||
makeEvent,
|
||||
createAssetUpsertEvent,
|
||||
createAssetDeleteEvent,
|
||||
createObjectUpsertEvent,
|
||||
createObjectDeleteEvent,
|
||||
deleteAssetOnly,
|
||||
applyEvent,
|
||||
cacheAssets,
|
||||
readCachedAssets,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue