From 7fd7ba2e76b70dd8259d6f2a3416093847cc1f0a Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Thu, 4 Jun 2026 16:40:45 +0900 Subject: [PATCH] split --- admin/index.php | 92 ++++++++++++++++-- app.js | 222 +++++++++++++++++--------------------------- index.html | 5 +- js/palette.js | 58 ++++++++++++ js/pixel-codec.js | 175 ++++++++++++++++++++++++++++++++++ js/pixel-worker.js | 21 +++++ js/worker-client.js | 53 +++++++++++ styles.css | 33 +++++++ 8 files changed, 512 insertions(+), 147 deletions(-) create mode 100644 js/palette.js create mode 100644 js/pixel-codec.js create mode 100644 js/pixel-worker.js create mode 100644 js/worker-client.js 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: / Users: / Pictures: / 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; +}