diff --git a/admin/index.php b/admin/index.php
index 60771cc..a16273e 100644
--- a/admin/index.php
+++ b/admin/index.php
@@ -61,10 +61,25 @@ function thumbnail_html($json): string {
if (!$rows) {
for ($y = 0; $y < $height; $y++) $rows[] = substr($pixels, $y * $width, $width);
}
- $palette = [
- '0'=>'#1f2330','1'=>'#ffffff','2'=>'#8f9aa8','3'=>'#d7c59a','4'=>'#7a4f35','5'=>'#3f6f3f','6'=>'#5c8f52','7'=>'#8fcf68','8'=>'#2f5f88','9'=>'#67b7dc',
- 'a'=>'#f2d3a3','b'=>'#e09f67','c'=>'#c96b4b','d'=>'#8c3f3f','e'=>'#d94f70','f'=>'#9b5fc0','g'=>'#5b4aa3','h'=>'#3f7fbf','i'=>'#55b7a5','j'=>'#f0d84f'
+ $codes = str_split('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' . "!#$%&'()*+,-/:;<=>?@[]^_`{|}~");
+ $colors = [
+ '#111827', '#374151', '#6b7280', '#d1d5db', '#fff7ed',
+ '#7f1d1d', '#dc2626', '#f97316', '#facc15', '#84cc16',
+ '#16a34a', '#14b8a6', '#06b6d4', '#2563eb', '#4f46e5',
+ '#7c3aed', '#c026d3', '#ec4899', '#f5d0a9', '#7c4a2d',
+ '#fafafa', '#f5f5f4', '#e7e5e4', '#d6d3d1', '#a8a29e', '#78716c', '#57534e', '#292524', '#0c0a09',
+ '#fef3c7', '#fde68a', '#d6a25f', '#9a6a3a', '#5c3b24',
+ '#fee2e2', '#fca5a5', '#ef4444', '#b91c1c', '#7f1d1d',
+ '#ffedd5', '#fdba74', '#f97316', '#c2410c', '#7c2d12',
+ '#fef9c3', '#fde047', '#eab308', '#a16207', '#713f12',
+ '#ecfccb', '#bef264', '#84cc16', '#4d7c0f', '#365314', '#dcfce7', '#86efac', '#15803d',
+ '#ccfbf1', '#5eead4', '#14b8a6', '#0f766e', '#134e4a',
+ '#cffafe', '#67e8f9', '#22d3ee', '#06b6d4', '#0e7490', '#e0f2fe', '#38bdf8',
+ '#dbeafe', '#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a',
+ '#e0e7ff', '#a5b4fc', '#6366f1', '#4f46e5', '#3730a3', '#ede9fe', '#c4b5fd', '#8b5cf6',
+ '#fae8ff', '#e879f9', '#c026d3', '#86198f', '#fce7f3', '#f9a8d4', '#ec4899', '#be185d'
];
+ $palette = array_combine($codes, array_slice($colors, 0, count($codes))) ?: [];
$cells = '';
foreach (array_slice($rows, 0, $height) as $y => $row) {
$chars = preg_split('//u', (string)$row, -1, PREG_SPLIT_NO_EMPTY) ?: [];
@@ -72,7 +87,7 @@ function thumbnail_html($json): string {
$ch = $chars[$x] ?? '.';
if ($ch === '.') continue;
$color = $palette[$ch] ?? sprintf('#%06x', (hexdec(substr(md5($ch), 0, 6)) & 0x7fffff) | 0x303030);
- $cells .= '';
+ $cells .= '';
}
}
return '
' . $cells . '
';
@@ -108,6 +123,12 @@ function sqlite_db(string $path): PDO {
$db->exec('PRAGMA busy_timeout = 5000');
return $db;
}
+function ensure_sqlite_anonymous_account(PDO $db): void {
+ $now = now_ms();
+ $hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT);
+ $db->prepare('INSERT OR IGNORE INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
+ ->execute(['anonymous', 'Anonymous', $hash, $now, $now]);
+}
function list_sqlite_accounts(string $path): array {
$db = sqlite_db($path);
if (!sqlite_tables_ready($db)) return [];
@@ -186,8 +207,25 @@ function apply_sqlite_admin_action(string $path, array $post): void {
$accountId = clean_id($post['account_id'] ?? '');
if ($accountId === '' || $accountId === 'island-team') return;
$db->beginTransaction();
- $db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE owner_account_id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $accountId]);
- $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE owner_account_id = ? AND deleted_at IS NULL')->execute([$now, 'admin', $now, $accountId]);
+ ensure_sqlite_anonymous_account($db);
+ $assetRows = $db->prepare('SELECT id, json FROM assets WHERE owner_account_id = ?');
+ $assetRows->execute([$accountId]);
+ foreach ($assetRows->fetchAll() ?: [] as $row) {
+ $asset = decode_json_value($row['json'] ?? '');
+ $asset['ownerAccountId'] = 'anonymous';
+ $asset['author'] = 'Anonymous';
+ $asset['updatedAt'] = $now;
+ $db->prepare('UPDATE assets SET owner_account_id = ?, author_name = ?, json = ?, updated_at = ? WHERE id = ?')
+ ->execute(['anonymous', 'Anonymous', encode_json_value($asset), $now, $row['id']]);
+ }
+ $objectRows = $db->prepare('SELECT id, json FROM objects WHERE owner_account_id = ?');
+ $objectRows->execute([$accountId]);
+ foreach ($objectRows->fetchAll() ?: [] as $row) {
+ $object = decode_json_value($row['json'] ?? '');
+ $object['ownerAccountId'] = 'anonymous';
+ $db->prepare('UPDATE objects SET owner_account_id = ?, json = ?, updated_at = ? WHERE id = ?')
+ ->execute(['anonymous', encode_json_value($object), $now, $row['id']]);
+ }
$db->prepare('DELETE FROM votes WHERE voter_account_id = ?')->execute([$accountId]);
$db->prepare("UPDATE reports SET status = 'closed' WHERE reporter_account_id = ?")->execute([$accountId]);
$db->prepare('DELETE FROM accounts WHERE id = ?')->execute([$accountId]);
@@ -267,6 +305,12 @@ function apply_filedb_admin_action(string $path, array $post): void {
write_filedb($path, function(array &$db) use ($post) {
$action = (string)($post['admin_action'] ?? $post['moderation_action'] ?? '');
$now = now_ms();
+ $ensureAnonymous = function() use (&$db, $now): void {
+ $db['accounts'] = is_array($db['accounts'] ?? null) ? $db['accounts'] : [];
+ if (!isset($db['accounts']['anonymous'])) {
+ $db['accounts']['anonymous'] = ['id'=>'anonymous','name'=>'Anonymous','password_hash'=>password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
+ }
+ };
if ($action === 'rename_account') {
$accountId = clean_id($post['account_id'] ?? '');
$name = clean_text($post['name'] ?? '', '', 32);
@@ -292,9 +336,25 @@ function apply_filedb_admin_action(string $path, array $post): void {
} elseif ($action === 'delete_account') {
$accountId = clean_id($post['account_id'] ?? '');
if ($accountId === '' || $accountId === 'island-team') return;
- foreach ($db['assets'] as &$asset) if (($asset['owner_account_id'] ?? '') === $accountId && empty($asset['deleted_at'])) { $asset['deleted_at'] = $now; $asset['deleted_by'] = 'admin'; $asset['updated_at'] = $now; }
+ $ensureAnonymous();
+ foreach ($db['assets'] as &$asset) if (($asset['owner_account_id'] ?? '') === $accountId) {
+ $asset['owner_account_id'] = 'anonymous';
+ $asset['author_name'] = 'Anonymous';
+ $asset['updated_at'] = $now;
+ $json = decode_json_value($asset['json'] ?? []);
+ $json['ownerAccountId'] = 'anonymous';
+ $json['author'] = 'Anonymous';
+ $json['updatedAt'] = $now;
+ $asset['json'] = $json;
+ }
unset($asset);
- foreach ($db['objects'] as &$object) if (($object['owner_account_id'] ?? '') === $accountId && empty($object['deleted_at'])) { $object['deleted_at'] = $now; $object['deleted_by'] = 'admin'; $object['updated_at'] = $now; }
+ foreach ($db['objects'] as &$object) if (($object['owner_account_id'] ?? '') === $accountId) {
+ $object['owner_account_id'] = 'anonymous';
+ $object['updated_at'] = $now;
+ $json = decode_json_value($object['json'] ?? []);
+ $json['ownerAccountId'] = 'anonymous';
+ $object['json'] = $json;
+ }
unset($object);
foreach ($db['reports'] as &$report) if (($report['reporter_account_id'] ?? '') === $accountId) $report['status'] = 'closed';
unset($report);
@@ -373,8 +433,20 @@ $baseUrl = './?token=' . rawurlencode($given);
Pixel Island Admin
+
Pixel Island Admin
Store: =h($storeLabel)?> / Users: =count($accounts)?> / Pictures: =count($assets)?> / Reports: =count($reports)?>
@@ -398,7 +470,7 @@ body{font-family:system-ui,sans-serif;margin:24px;background:#f7f4ea;color:#2b2b
-
+
diff --git a/app.js b/app.js
index 6a81475..f375fa4 100644
--- a/app.js
+++ b/app.js
@@ -31,19 +31,22 @@
const CURSOR_LIGHT_WORLD_RADIUS = 86;
const CURSOR_LIGHT_INTENSITY = 0.15625;
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;
- const BASIC_PALETTE_COUNT = BASE_COLOR_CODES.length;
- const DEFAULT_SELECTED_COLOR_CODE = BASE_COLOR_CODES.includes('a') ? 'a' : (BASE_COLOR_CODES[0] || '0');
- const PALETTE = buildPalette();
- const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const MODULES = window.PixelIslandModules || {};
+ const PaletteModule = MODULES.Palette || {};
+ const BASE_COLOR_CODES = PaletteModule.BASE_COLOR_CODES || '0123456789abcdefghij';
+ const ADVANCED_COLOR_CODES = PaletteModule.ADVANCED_COLOR_CODES || ('klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~");
+ const COLOR_CODES = PaletteModule.COLOR_CODES || (BASE_COLOR_CODES + ADVANCED_COLOR_CODES);
+ const BASIC_PALETTE_COUNT = PaletteModule.BASIC_PALETTE_COUNT || BASE_COLOR_CODES.length;
+ const DEFAULT_SELECTED_COLOR_CODE = PaletteModule.DEFAULT_SELECTED_COLOR_CODE || (BASE_COLOR_CODES.includes('a') ? 'a' : (BASE_COLOR_CODES[0] || '0'));
+ const PALETTE = PaletteModule.PALETTE || buildPalette();
+ const PALETTE_BY_CODE = PaletteModule.PALETTE_BY_CODE || Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const Phase2Sync = MODULES.Phase2Sync || null;
const StateIndex = MODULES.StateIndex || null;
const RotationPolicy = MODULES.RotationPolicy || null;
const Lighting = MODULES.Lighting || null;
const ModuleLoader = MODULES.ModuleLoader || null;
+ const WorkerClient = MODULES.WorkerClient || null;
+ const PixelCodec = MODULES.PixelCodec || {};
const EDITOR_HISTORY_LIMIT = 80;
const MAX_EDITOR_DIMENSION = 64;
const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 };
@@ -58,7 +61,7 @@
function clampDimension(value, fallback = 8) {
- return clampInt ? clampInt(value, 1, MAX_EDITOR_DIMENSION, fallback) : Math.max(1, Math.min(MAX_EDITOR_DIMENSION, Math.round(Number(value) || fallback)));
+ return PixelCodec.clampDimension ? PixelCodec.clampDimension(value, fallback) : Math.max(1, Math.min(MAX_EDITOR_DIMENSION, Math.round(Number(value) || fallback)));
}
function rectArea(width, height = width) {
@@ -70,15 +73,72 @@
}
function assetWidth(asset) {
- return clampInt(asset?.width ?? asset?.w ?? asset?.size, 1, MAX_EDITOR_DIMENSION, 16);
+ return PixelCodec.assetWidth ? PixelCodec.assetWidth(asset) : clampDimension(asset?.width ?? asset?.w ?? asset?.size, 16);
}
function assetHeight(asset) {
- return clampInt(asset?.height ?? asset?.ht ?? asset?.size, 1, MAX_EDITOR_DIMENSION, assetWidth(asset));
+ return PixelCodec.assetHeight ? PixelCodec.assetHeight(asset) : clampDimension(asset?.height ?? asset?.ht ?? asset?.size, assetWidth(asset));
}
function assetMaxSize(asset) {
- return Math.max(assetWidth(asset), assetHeight(asset));
+ return PixelCodec.assetMaxSize ? PixelCodec.assetMaxSize(asset) : Math.max(assetWidth(asset), assetHeight(asset));
+ }
+
+ function clampInt(value, min, max, fallback = min) {
+ const n = Math.round(Number(value));
+ return PixelCodec.clampInt ? PixelCodec.clampInt(value, min, max, fallback) : (Number.isFinite(n) ? clamp(n, min, max) : fallback);
+ }
+
+ function buildPixelBlob(encodedPixels, width, height = width) {
+ return PixelCodec.buildPixelBlob(encodedPixels, width, height, PALETTE, DEFAULT_SELECTED_COLOR_CODE);
+ }
+
+ function computePixelBlobId(codec, width, height, payload) {
+ return PixelCodec.computePixelBlobId(codec, width, height, payload, PALETTE, DEFAULT_SELECTED_COLOR_CODE);
+ }
+
+ function ensureAssetBlobId(asset) {
+ return PixelCodec.ensureAssetBlobId(asset, PALETTE, DEFAULT_SELECTED_COLOR_CODE);
+ }
+
+ function blankPixels(width, height = width) {
+ return PixelCodec.blankPixels(width, height);
+ }
+
+ function normalizePixels(pixels, width, height = width) {
+ return PixelCodec.normalizePixels(pixels, width, height, PALETTE, DEFAULT_SELECTED_COLOR_CODE);
+ }
+
+ function encodePixels(pixels, width = null, height = null) {
+ return PixelCodec.encodePixels(pixels, width, height, PALETTE, DEFAULT_SELECTED_COLOR_CODE);
+ }
+
+ function colorToHex(value) {
+ return PixelCodec.colorToHex(value);
+ }
+
+ function nearestPaletteCode(color) {
+ return PixelCodec.nearestPaletteCode(color);
+ }
+
+ function nearestBasicPaletteCode(color) {
+ return PixelCodec.nearestBasicPaletteCode(color);
+ }
+
+ function nearestPaletteCodeFrom(entries, color, fallback) {
+ return PixelCodec.nearestPaletteCodeFrom(entries, color, fallback);
+ }
+
+ function readableTextColor(hex) {
+ return PixelCodec.readableTextColor(hex);
+ }
+
+ function parseHex(hex) {
+ return PixelCodec.parseHex(hex);
+ }
+
+ function fnv1a(value) {
+ return PixelCodec.fnv1a(value);
}
function editorCellSize() {
@@ -6973,8 +7033,20 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
function cachePhase2Snapshot() {
if (Phase2Sync?.cachePixelBlobs) {
- const blobs = (state.assets || []).map((asset) => buildPixelBlob(asset.faces?.right || asset.pixels || blankPixels(assetWidth(asset), assetHeight(asset)), assetWidth(asset), assetHeight(asset)));
- Phase2Sync.cachePixelBlobs(blobs).catch((error) => console.warn('Phase 2 pixel blob cache failed.', error));
+ const assets = state.assets || [];
+ const buildBlobsOnMainThread = () => assets.map((asset) => buildPixelBlob(asset.faces?.right || asset.pixels || blankPixels(assetWidth(asset), assetHeight(asset)), assetWidth(asset), assetHeight(asset)));
+ const cacheBlobs = (blobs) => Phase2Sync.cachePixelBlobs(blobs).catch((error) => console.warn('Phase 2 pixel blob cache failed.', error));
+
+ if (WorkerClient?.canUseWorkers?.() && WorkerClient?.buildPixelBlobs) {
+ WorkerClient.buildPixelBlobs(assets)
+ .then(cacheBlobs)
+ .catch((error) => {
+ console.warn('Pixel worker blob build failed; using main thread.', error);
+ cacheBlobs(buildBlobsOnMainThread());
+ });
+ } else {
+ cacheBlobs(buildBlobsOnMainThread());
+ }
}
if (Phase2Sync?.cacheAssets) {
Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error));
@@ -7013,37 +7085,6 @@ 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 || '',
@@ -7077,101 +7118,10 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
&& normalizeOwnerAccountId(item.ownerAccountId, currentAccountId()) === owner) || null;
}
- function fnv1a(value) {
- let hash = 0x811c9dc5;
- for (let i = 0; i < value.length; i++) {
- hash ^= value.charCodeAt(i);
- hash = Math.imul(hash, 0x01000193) >>> 0;
- }
- return hash.toString(16).padStart(8, '0');
- }
-
- function clampInt(value, min, max, fallback = min) {
- const n = Math.round(Number(value));
- return Number.isFinite(n) ? clamp(n, min, max) : fallback;
- }
-
function findAsset(id) {
return worldIndex.assetById?.get(id) || state.assets.find((asset) => asset.id === id) || null;
}
- function blankPixels(width, height = width) {
- return Array(Math.max(1, width) * Math.max(1, height)).fill(null);
- }
-
- function normalizePixels(pixels, width, height = width) {
- const out = blankPixels(width, height);
- if (typeof pixels === 'string') {
- for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
- const ch = pixels[i];
- out[i] = ch === '.' ? null : (PALETTE_BY_CODE[ch] ? ch : nearestPaletteCode(ch));
- }
- return out;
- }
- if (!Array.isArray(pixels)) return out;
- for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
- const value = pixels[i];
- if (!value) out[i] = null;
- else if (PALETTE_BY_CODE[value]) out[i] = value;
- else out[i] = nearestPaletteCode(value);
- }
- return out;
- }
-
- function encodePixels(pixels, width = null, height = null) {
- const w = width || Math.sqrt(pixels?.length || 0) || editorSize;
- const h = height || w;
- return normalizePixels(pixels, w, h).map((value) => value || '.').join('');
- }
-
- function colorToHex(value) {
- if (!value) return 'rgba(0,0,0,0)';
- return PALETTE_BY_CODE[value] || value;
- }
-
- function nearestPaletteCode(color) {
- // This can run during loadState()/seedState() before selectedColorCode is initialized.
- // Keep the fallback independent from editor state to avoid TDZ startup crashes.
- return nearestPaletteCodeFrom(PALETTE, color, DEFAULT_SELECTED_COLOR_CODE);
- }
-
- function nearestBasicPaletteCode(color) {
- return nearestPaletteCodeFrom(PALETTE.slice(0, BASIC_PALETTE_COUNT), PALETTE_BY_CODE[color] || color, PALETTE[0].code);
- }
-
- function nearestPaletteCodeFrom(entries, color, fallback) {
- if (!color || typeof color !== 'string') return fallback || entries[0]?.code || PALETTE[0].code;
- if (entries.some((entry) => entry.code === color)) return color;
- const rgb = parseHex(color);
- if (!rgb) return fallback || entries[0]?.code || PALETTE[0].code;
- let best = entries[0]?.code || PALETTE[0].code;
- let bestDist = Infinity;
- for (const entry of entries) {
- const p = parseHex(entry.color);
- const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2;
- if (dist < bestDist) { bestDist = dist; best = entry.code; }
- }
- return best;
- }
-
- function readableTextColor(hex) {
- const rgb = parseHex(hex);
- if (!rgb) return '#243044';
- const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
- return yiq > 140 ? '#243044' : '#fffdf5';
- }
-
- function parseHex(hex) {
- if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
- const clean = hex.replace('#', '');
- const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
- const value = parseInt(full, 16);
- if (!Number.isFinite(value)) return null;
- return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
- }
-
-
-
function normalizeDepthPixels(input, width, height = width) {
const out = Array(Math.max(1, width) * Math.max(1, height)).fill(0);
if (typeof input === 'string') {
@@ -7721,4 +7671,4 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
// Pixel Island default-art drawing code was moved out of app.js.
window.PixelIslandDebug = { ...(window.PixelIslandDebug || {}), applySyncEvent };
bootstrap();
-})();
+})();
\ No newline at end of file
diff --git a/index.html b/index.html
index 60c1ad4..3a710b5 100644
--- a/index.html
+++ b/index.html
@@ -86,6 +86,7 @@
+
@@ -100,7 +101,6 @@
-
@@ -255,6 +255,9 @@
+
+
+
diff --git a/js/palette.js b/js/palette.js
new file mode 100644
index 0000000..470c619
--- /dev/null
+++ b/js/palette.js
@@ -0,0 +1,58 @@
+(function () {
+ 'use strict';
+
+ const root = window.PixelIslandModules ||= {};
+ const BASE_COLOR_CODES = '0123456789abcdefghij';
+ const ADVANCED_COLOR_CODES = 'klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~";
+ const COLOR_CODES = BASE_COLOR_CODES + ADVANCED_COLOR_CODES;
+ const BASIC_PALETTE_COUNT = BASE_COLOR_CODES.length;
+ const DEFAULT_SELECTED_COLOR_CODE = BASE_COLOR_CODES.includes('a') ? 'a' : (BASE_COLOR_CODES[0] || '0');
+
+ function hslToHex(h, s, l) {
+ s /= 100; l /= 100;
+ const k = (n) => (n + h / 30) % 12;
+ const a = s * Math.min(l, 1 - l);
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
+ const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0');
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
+ }
+
+ function buildPalette() {
+ const basicColors = [
+ '#111827', '#374151', '#6b7280', '#d1d5db', '#fff7ed',
+ '#7f1d1d', '#dc2626', '#f97316', '#facc15', '#84cc16',
+ '#16a34a', '#14b8a6', '#06b6d4', '#2563eb', '#4f46e5',
+ '#7c3aed', '#c026d3', '#ec4899', '#f5d0a9', '#7c4a2d'
+ ];
+ const advancedColors = [
+ '#fafafa', '#f5f5f4', '#e7e5e4', '#d6d3d1', '#a8a29e', '#78716c', '#57534e', '#292524', '#0c0a09',
+ '#fef3c7', '#fde68a', '#d6a25f', '#9a6a3a', '#5c3b24',
+ '#fee2e2', '#fca5a5', '#ef4444', '#b91c1c', '#7f1d1d',
+ '#ffedd5', '#fdba74', '#f97316', '#c2410c', '#7c2d12',
+ '#fef9c3', '#fde047', '#eab308', '#a16207', '#713f12',
+ '#ecfccb', '#bef264', '#84cc16', '#4d7c0f', '#365314', '#dcfce7', '#86efac', '#15803d',
+ '#ccfbf1', '#5eead4', '#14b8a6', '#0f766e', '#134e4a',
+ '#cffafe', '#67e8f9', '#22d3ee', '#06b6d4', '#0e7490', '#e0f2fe', '#38bdf8',
+ '#dbeafe', '#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a',
+ '#e0e7ff', '#a5b4fc', '#6366f1', '#4f46e5', '#3730a3', '#ede9fe', '#c4b5fd', '#8b5cf6',
+ '#fae8ff', '#e879f9', '#c026d3', '#86198f', '#fce7f3', '#f9a8d4', '#ec4899', '#be185d'
+ ];
+ const colors = [...basicColors, ...advancedColors];
+ return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] || hslToHex((index * 47) % 360, 72, 58) }));
+ }
+
+ const PALETTE = buildPalette();
+ const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
+
+ root.Palette = {
+ BASE_COLOR_CODES,
+ ADVANCED_COLOR_CODES,
+ COLOR_CODES,
+ BASIC_PALETTE_COUNT,
+ DEFAULT_SELECTED_COLOR_CODE,
+ PALETTE,
+ PALETTE_BY_CODE,
+ buildPalette,
+ hslToHex
+ };
+})();
diff --git a/js/pixel-codec.js b/js/pixel-codec.js
new file mode 100644
index 0000000..2a5f9f3
--- /dev/null
+++ b/js/pixel-codec.js
@@ -0,0 +1,175 @@
+(function (global) {
+ 'use strict';
+
+ const root = global.PixelIslandModules ||= {};
+ const MAX_DIMENSION = 64;
+
+ function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ function clampInt(value, min, max, fallback = min) {
+ const n = Math.round(Number(value));
+ return Number.isFinite(n) ? clamp(n, min, max) : fallback;
+ }
+
+ function clampDimension(value, fallback = 8) {
+ return clampInt(value, 1, MAX_DIMENSION, fallback);
+ }
+
+ function fnv1a(value) {
+ let hash = 0x811c9dc5;
+ const text = String(value);
+ for (let i = 0; i < text.length; i++) {
+ hash ^= text.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return hash.toString(16).padStart(8, '0');
+ }
+
+ function assetWidth(asset) {
+ return clampInt(asset?.width ?? asset?.w ?? asset?.size, 1, MAX_DIMENSION, 16);
+ }
+
+ function assetHeight(asset) {
+ return clampInt(asset?.height ?? asset?.ht ?? asset?.size, 1, MAX_DIMENSION, assetWidth(asset));
+ }
+
+ function assetMaxSize(asset) {
+ return Math.max(assetWidth(asset), assetHeight(asset));
+ }
+
+ function blankPixels(width, height = width) {
+ return Array(Math.max(1, width) * Math.max(1, height)).fill(null);
+ }
+
+ function parseHex(hex) {
+ if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
+ const clean = hex.replace('#', '');
+ const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
+ const value = parseInt(full, 16);
+ if (!Number.isFinite(value)) return null;
+ return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
+ }
+
+ function nearestPaletteCodeFrom(entries, color, fallback) {
+ if (!Array.isArray(entries) || !entries.length) return fallback || '0';
+ if (!color || typeof color !== 'string') return fallback || entries[0]?.code || '0';
+ if (entries.some((entry) => entry.code === color)) return color;
+ const rgb = parseHex(color);
+ if (!rgb) return fallback || entries[0]?.code || '0';
+ let best = entries[0]?.code || '0';
+ let bestDist = Infinity;
+ for (const entry of entries) {
+ const entryRgb = parseHex(entry.color);
+ if (!entryRgb) continue;
+ const dist = (rgb.r - entryRgb.r) ** 2 + (rgb.g - entryRgb.g) ** 2 + (rgb.b - entryRgb.b) ** 2;
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = entry.code;
+ }
+ }
+ return best;
+ }
+
+ function normalizePixels(pixels, width, height = width, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
+ const out = blankPixels(width, height);
+ const paletteByCode = new Map((palette || []).map((entry) => [entry.code, entry.color]));
+ if (typeof pixels === 'string') {
+ for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
+ const ch = pixels[i];
+ out[i] = ch === '.' ? null : (paletteByCode.has(ch) ? ch : nearestPaletteCodeFrom(palette, ch, defaultColorCode));
+ }
+ return out;
+ }
+ if (!Array.isArray(pixels)) return out;
+ for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
+ const value = pixels[i];
+ if (!value) out[i] = null;
+ else if (paletteByCode.has(value)) out[i] = value;
+ else out[i] = nearestPaletteCodeFrom(palette, value, defaultColorCode);
+ }
+ return out;
+ }
+
+ function encodePixels(pixels, width = null, height = null, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
+ const w = width || Math.sqrt(pixels?.length || 0) || 8;
+ const h = height || w;
+ return normalizePixels(pixels, w, h, palette, defaultColorCode).map((value) => value || '.').join('');
+ }
+
+ function buildPixelBlob(encodedPixels, width, height = width, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
+ const w = clampDimension(width, 16);
+ const h = clampDimension(height, w);
+ const payload = encodePixels(encodedPixels, w, h, palette, defaultColorCode);
+ const codec = 'palette-index-v1';
+ return {
+ id: computePixelBlobId(codec, w, h, payload, palette, defaultColorCode),
+ codec,
+ width: w,
+ height: h,
+ payload
+ };
+ }
+
+ function computePixelBlobId(codec, width, height, payload, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
+ const normalizedCodec = codec || 'palette-index-v1';
+ const w = clampDimension(width, 16);
+ const h = clampDimension(height, w);
+ const encoded = encodePixels(payload, w, h, palette, defaultColorCode);
+ return `blob:${fnv1a(['pixel-blob-v1', normalizedCodec, w, h, encoded].join('|'))}`;
+ }
+
+ function ensureAssetBlobId(asset, palette = root.Palette?.PALETTE || [], defaultColorCode = root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a') {
+ 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, palette, defaultColorCode);
+ const blob = buildPixelBlob(right, w, h, palette, defaultColorCode);
+ return { ...asset, blobId: asset.blobId || asset.bi || blob.id };
+ }
+
+ function colorToHex(value) {
+ if (!value) return 'rgba(0,0,0,0)';
+ return root.Palette?.PALETTE_BY_CODE?.[value] || value;
+ }
+
+ function nearestPaletteCode(color) {
+ return nearestPaletteCodeFrom(root.Palette?.PALETTE || [], color, root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a');
+ }
+
+ function nearestBasicPaletteCode(color) {
+ const palette = root.Palette?.PALETTE || [];
+ const count = root.Palette?.BASIC_PALETTE_COUNT || palette.length;
+ return nearestPaletteCodeFrom(palette.slice(0, count), root.Palette?.PALETTE_BY_CODE?.[color] || color, palette[0]?.code || '0');
+ }
+
+ function readableTextColor(hex) {
+ const rgb = parseHex(hex);
+ if (!rgb) return '#243044';
+ const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
+ return yiq > 140 ? '#243044' : '#fffdf5';
+ }
+
+ root.PixelCodec = {
+ MAX_DIMENSION,
+ assetHeight,
+ assetMaxSize,
+ assetWidth,
+ blankPixels,
+ buildPixelBlob,
+ clampDimension,
+ clampInt,
+ colorToHex,
+ computePixelBlobId,
+ ensureAssetBlobId,
+ encodePixels,
+ fnv1a,
+ nearestBasicPaletteCode,
+ nearestPaletteCode,
+ nearestPaletteCodeFrom,
+ normalizePixels,
+ parseHex,
+ readableTextColor
+ };
+})(typeof self !== 'undefined' ? self : window);
diff --git a/js/pixel-worker.js b/js/pixel-worker.js
new file mode 100644
index 0000000..15c4719
--- /dev/null
+++ b/js/pixel-worker.js
@@ -0,0 +1,21 @@
+'use strict';
+
+importScripts('./pixel-codec.js');
+
+const PixelCodec = self.PixelIslandModules.PixelCodec;
+
+self.onmessage = (event) => {
+ const { id, type, payload } = event.data || {};
+ try {
+ if (type === 'buildPixelBlobs') {
+ const palette = Array.isArray(payload?.palette) ? payload.palette : [];
+ const defaultColorCode = payload?.defaultColorCode || 'a';
+ const blobs = (payload?.assets || []).map((asset) => PixelCodec.buildPixelBlob(asset?.faces?.right || asset?.pixels || '', PixelCodec.assetWidth(asset), PixelCodec.assetHeight(asset), palette, defaultColorCode));
+ self.postMessage({ id, ok: true, result: blobs });
+ return;
+ }
+ self.postMessage({ id, ok: false, error: 'unsupported_worker_request' });
+ } catch (error) {
+ self.postMessage({ id, ok: false, error: error?.message || 'worker_error' });
+ }
+};
diff --git a/js/worker-client.js b/js/worker-client.js
new file mode 100644
index 0000000..94410fa
--- /dev/null
+++ b/js/worker-client.js
@@ -0,0 +1,53 @@
+(function () {
+ 'use strict';
+
+ const root = window.PixelIslandModules ||= {};
+ let worker = null;
+ let nextId = 1;
+ const pending = new Map();
+
+ function canUseWorkers() {
+ return typeof Worker === 'function';
+ }
+
+ function getWorker() {
+ if (!canUseWorkers()) return null;
+ if (worker) return worker;
+ worker = new Worker('./js/pixel-worker.js');
+ worker.onmessage = (event) => {
+ const { id, ok, result, error } = event.data || {};
+ const entry = pending.get(id);
+ if (!entry) return;
+ pending.delete(id);
+ if (ok) entry.resolve(result);
+ else entry.reject(new Error(error || 'worker_error'));
+ };
+ worker.onerror = (event) => {
+ const error = new Error(event.message || 'worker_error');
+ for (const entry of pending.values()) entry.reject(error);
+ pending.clear();
+ worker?.terminate();
+ worker = null;
+ };
+ return worker;
+ }
+
+ function request(type, payload) {
+ const target = getWorker();
+ if (!target) return Promise.reject(new Error('workers_unavailable'));
+ const id = nextId++;
+ const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
+ target.postMessage({ id, type, payload });
+ return promise;
+ }
+
+ function buildPixelBlobs(assets) {
+ return request('buildPixelBlobs', {
+ assets,
+ palette: root.Palette?.PALETTE || [],
+ defaultColorCode: root.Palette?.DEFAULT_SELECTED_COLOR_CODE || 'a'
+ });
+ }
+
+ root.WorkerClient = { canUseWorkers, buildPixelBlobs };
+})();
diff --git a/styles.css b/styles.css
index 4023006..4ba565e 100644
--- a/styles.css
+++ b/styles.css
@@ -2937,3 +2937,36 @@ body, button, input, select, textarea { font-size: 15px; }
.reportDialog { z-index: 9998; }
.assetStatus.validating { background: #fff2a8; }
.assetStatus.failed { background: #ffe2e2; color: #8b2424; }
+
+/* Shared-only editor polish: five obvious primary draw controls. */
+.editorToolRow,
+.basicEditorTools,
+.studioDrawer.advancedTools .editorToolRow,
+.studioDrawer.advancedTools .basicEditorTools {
+ grid-template-columns: repeat(5, minmax(0, 1fr)) !important;
+}
+.basicEditorTools #toolBrush {
+ background: linear-gradient(180deg, #dcfce7, #86efac) !important;
+ color: #12422a !important;
+}
+.basicEditorTools #toolErase {
+ background: linear-gradient(180deg, #fee2e2, #fca5a5) !important;
+ color: #6f1515 !important;
+}
+.basicEditorTools #toolFill {
+ background: linear-gradient(180deg, #dbeafe, #93c5fd) !important;
+ color: #123f7a !important;
+}
+.basicEditorTools #toolPick {
+ background: linear-gradient(180deg, #fef3c7, #fde68a) !important;
+ color: #70470a !important;
+}
+.basicEditorTools #clearPaint {
+ background: linear-gradient(180deg, #ffe4e6, #fb7185) !important;
+ color: #711827 !important;
+}
+.basicEditorTools .tool.active {
+ outline: 4px solid rgba(36, 48, 68, .55) !important;
+ outline-offset: -6px !important;
+ color: #111827 !important;
+}