(() => { 'use strict'; console.info('Pixel Island loaded'); const STORAGE_KEY = 'pixel-island-summoner:phase6b'; const LEGACY_STORAGE_KEYS = ['pixel-island-summoner:phase6a', 'pixel-island-summoner:phase6b:schema27']; const SAVE_SCHEMA = 32; const LOCAL_MANIFEST_FORMAT = 'pixel-island-local-manifest-v1'; const SHARED_API_ENDPOINT = './api/index.php'; const SHARED_SYNC_INTERVAL_MS = 5000; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; const TILE_H = 16; const ORIGIN_X = WORLD_H * TILE_W / 2 + 80; const ORIGIN_Y = 42; const DAY_MS = 10 * 60 * 1000; const STATIC_SCALE = 2; const DYNAMIC_SCALE = 1; const MAX_ZOOM = 3.2; const MIN_ZOOM = 0.45; const TERRAIN_CHUNK_SIZE = 16; const VIEW_CULL_MARGIN = 192; const DYNAMIC_LOGIC_STEP_MS = 1000 / 15; const TARGET_RENDER_FRAME_MS = 1000 / 30; const MAX_DYNAMIC_STEPS_PER_FRAME = 3; const MAX_SPRITE_CACHE_ENTRIES = 260; const DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER = 5; const CURSOR_LIGHT_COLOR = '#dff6ff'; const CURSOR_LIGHT_WORLD_RADIUS = 86; const CURSOR_LIGHT_INTENSITY = 0.15625; const CURSOR_DEPTH_REFLECTION_MULTIPLIER = 1.85; 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 }; const editorActions = () => MODULES.EditorActions || null; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5); const mod = (n, m) => ((n % m) + m) % m; const lerp = (a, b, t) => a + (b - a) * t; function clampDimension(value, fallback = 8) { 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) { return Math.max(1, Math.floor(Number(width) || 1)) * Math.max(1, Math.floor(Number(height) || width || 1)); } function lightBudgetForArea(width, height = width) { return Math.max(1, Math.ceil(rectArea(width, height) / 100)); } function assetWidth(asset) { return PixelCodec.assetWidth ? PixelCodec.assetWidth(asset) : clampDimension(asset?.width ?? asset?.w ?? asset?.size, 16); } function assetHeight(asset) { return PixelCodec.assetHeight ? PixelCodec.assetHeight(asset) : clampDimension(asset?.height ?? asset?.ht ?? asset?.size, assetWidth(asset)); } function assetMaxSize(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() { return Math.max(1, Math.floor(Math.min(els.paintCanvas.width / Math.max(1, editorWidth), els.paintCanvas.height / Math.max(1, editorHeight)))); } function updateDimensionInputs() { if (els.assetWidth) els.assetWidth.value = String(editorWidth); if (els.assetHeight) els.assetHeight.value = String(editorHeight); if (els.assetSize) { const preset = editorWidth === editorHeight && [8, 16, 32, 64].includes(editorWidth) ? editorWidth : ''; els.assetSize.value = preset ? String(preset) : String(Math.max(8, Math.min(64, editorSize))); } } function defaultVisualSettings() { return { enableLights: true, enableParticles: true, enableDayNight: true, localDisplayLimit: PHASE5_GUARDRAILS.defaultDisplayLimit }; } function visualSettings() { state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; return state.settings; } const DEFAULT_SERVER_AUTHORITY = Object.freeze({ publish: 'server', objectMove: 'server', dayNight: 'local', dynamicMotion: 'server' }); function serverAuthority() { ensureWorldProtectionState(); state.serverSync.authority = { ...DEFAULT_SERVER_AUTHORITY, ...(state.serverSync.authority || {}) }; return state.serverSync.authority; } function isServerAuthoritative(feature) { if (feature === 'dayNight') return false; return serverAuthority()[feature] === 'server'; } function getAuthoritativeNow() { return Date.now(); } function getPendingSharedVisuals() { ensureWorldProtectionState(); const visuals = state.serverSync.pendingObjectVisuals || {}; const now = Date.now(); const out = []; let dirty = false; for (const [id, entry] of Object.entries(visuals)) { if (!entry || !entry.object || !entry.assetId) { delete visuals[id]; dirty = true; continue; } // Keep optimistic shared-world placement visuals until a server event accepts or rejects them. // A short TTL made newly drawn works vanish while the command was still pending. if (Number(entry.expiresAt || 0) > 0 && now > Number(entry.expiresAt) && entry.expirePending === true) { delete visuals[id]; dirty = true; continue; } out.push(entry); } if (dirty) state.serverSync.pendingObjectVisuals = visuals; return out; } function hydrateVisualSettingsUI() { const settings = visualSettings(); if (els.settingLights) els.settingLights.checked = settings.enableLights !== false; if (els.settingParticles) els.settingParticles.checked = settings.enableParticles !== false; if (els.settingDayNight) els.settingDayNight.checked = settings.enableDayNight !== false; if (els.displayLimit) els.displayLimit.value = String(getLocalDisplayLimit()); } function clearVisualEffectState() { spawnEffects = []; bubbleParticles = []; confettiParticles = []; landStepParticles = []; natureDriftParticles = []; } function toggleHidden(el, hidden) { if (el) el.hidden = hidden; } function currentRole() { return els.assetCategory?.value || 'human'; } function roleToCategory(role) { return role === 'human' || role === 'animal' || role === 'bird' || role === 'fish' ? 'dynamic' : 'static'; } function roleToSubtype(role) { return role || 'other'; } function subtypeToRole(asset) { const subtype = asset?.subtype; if (['human', 'animal', 'bird', 'fish', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype; if (subtype === 'water') return 'other'; if (asset?.category === 'dynamic') return 'animal'; return 'other'; } const els = { canvas: $('worldCanvas'), openEditor: $('openEditor'), drawQuotaBadge: $('drawQuotaBadge'), finishQuotaBadge: $('finishQuotaBadge'), openCreate: $('openCreate'), openCollection: $('openCollection'), openMenu: $('openMenu'), closeEditor: $('closeEditor'), placeHereButton: $('placeHereButton'), createAccount: $('createAccount'), accountNote: $('accountNote'), accountId: $('accountId'), accountPass: $('accountPass'), drawer: $('studioDrawer'), tabs: [...document.querySelectorAll('.tab')], panels: [...document.querySelectorAll('.tabPanel')], phaseLabel: $('phaseLabel'), phaseBar: $('phaseBar'), analogClock: $('analogClock'), analogClockHand: $('analogClockHand'), authorName: $('authorName'), selectedAssetName: $('selectedAssetName'), tileInfo: $('tileInfo'), toast: $('toast'), selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleTeleport: $('bubbleTeleport'), bubbleMenu: $('bubbleMenu'), bubbleMenuActions: $('bubbleMenuActions'), bubbleEdit: $('bubbleEdit'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), zoomOut: $('zoomOut'), zoomIn: $('zoomIn'), resetView: $('resetView'), drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'), assetName: $('assetName'), assetSize: $('assetSize'), assetWidth: $('assetWidth'), assetHeight: $('assetHeight'), assetCategory: $('assetCategory'), staticKindWrap: $('staticKindWrap'), dynamicKindWrap: $('dynamicKindWrap'), roleHint: $('roleHint'), sideSwitcher: $('sideSwitcher'), frontSideSwitcher: $('frontSideSwitcher'), editRight: $('editRight'), editLeft: $('editLeft'), frontRight: $('frontRight'), frontLeft: $('frontLeft'), paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), toolLight: $('toolLight'), toolParticle: $('toolParticle'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), advancedToolGroup: $('advancedToolGroup'), particleDirectionWrap: $('particleDirectionWrap'), particleDirection: $('particleDirection'), particleUseSelection: $('particleUseSelection'), particleClearRange: $('particleClearRange'), particleRangeStatus: $('particleRangeStatus'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'), lineageNote: $('lineageNote'), assetList: $('assetList'), likedCodex: $('likedCodex'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), displayLimit: $('displayLimit'), rotationStats: $('rotationStats'), reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName'), confirmDialog: $('confirmDialog'), confirmTitle: $('confirmTitle'), confirmMessage: $('confirmMessage'), confirmOk: $('confirmOk'), confirmCancel: $('confirmCancel') }; const ctx = els.canvas.getContext('2d', { alpha: false }); const pctx = els.paintCanvas.getContext('2d', { alpha: true }); ctx.imageSmoothingEnabled = false; pctx.imageSmoothingEnabled = false; let dpr = window.devicePixelRatio || 1; let cw = 1; let ch = 1; let world = makeWorld(); let terrainCache = buildTerrainCache(world); let resetPhase2CacheOnBoot = false; let state = loadState(); let selectedAssetId = state.assets[0]?.id ?? null; let mode = 'inspect'; let view = { x: 0, y: 0, zoom: 1 }; let pointer = { down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0, dragging: false, downTime: 0, button: 0 }; let cursorScreen = { x: 0, y: 0, active: false }; let hoverTile = null; let dynamicRuntime = []; let spriteCache = new Map(); let lastRenderedSpriteInfo = new Map(); let lastRuntimeUpdate = performance.now(); let lastRenderAt = 0; let lastClockSecond = -1; let toastTimer = null; let editorWidth = 8; let editorHeight = 8; let editorSize = 8; let editorPixels = blankPixels(8); let editorLeftPixels = blankPixels(8); let editingSide = 'right'; let frontSide = 'right'; let paintTool = 'brush'; let selectedColorCode = DEFAULT_SELECTED_COLOR_CODE; let advancedDraw = false; let activeTabName = 'draw'; let depthPixels = blankPixels(8).map(() => 0); let depthPaintMode = 1; let isPainting = false; let staticKind = 'nature'; let dynamicKind = 'human'; let lightPixels = []; let particlePixels = []; let particleConfig = { enabled: false, c: 'f', dir: 'up' }; let doorPixel = { x: 8, y: 15 }; let editParentId = null; let editOriginalId = null; let editingAssetId = null; let lastPaintedKey = ''; let spawnEffects = []; let bubbleParticles = []; let confettiParticles = []; let landStepParticles = []; let natureDriftParticles = []; let coastalFoamTextures = null; let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; let shadowCanvasCache = new WeakMap(); let shipReflectionCanvasCache = new WeakMap(); let shadowMaskCanvas = null; let shadowMaskCtx = null; let activeShadowCtx = null; let animationFrameId = 0; let dynamicLogicRemainder = 0; let worldIndex = makeEmptyWorldIndex(); let selectedObject = null; let cameraFollowSelected = false; let pendingReport = null; let libraryFilter = 'all'; let librarySearch = ''; const uiState = { library: { viewTab: 'mine', search: '', filter: 'all', scroll: { bodyTop: 0, gridTop: 0 }, restoreToken: 0 }, selectedAssetId: null }; let libraryViewTab = uiState.library.viewTab; let libraryScrollTop = uiState.library.scroll.bodyTop; let libraryGridScrollTop = uiState.library.scroll.gridTop; let libraryScrollFrameToken = uiState.library.restoreToken; let renderPhase = null; let editorView = { zoom: 1, x: 0, y: 0 }; let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 }; let editorHistory = []; let editorFuture = []; let editorGestureSnapshot = null; let editorGestureChanged = false; let suppressNextPaintClick = false; let suppressMousePaintUntil = 0; let editorSelection = null; let selectionGesture = null; let shapeGesture = null; let shapePreview = null; let placementPreview = null; let sharedSyncTimer = 0; let sharedSyncInFlight = false; let sharedApiAvailable = null; let lastSharedSyncAt = 0; let suppressCanvasContextMenuOnce = false; async function bootstrap() { resizeCanvas(); resetView(false); await restoreStateFromIndexedDb(); rebuildWorldIndex(); hydrateRuntime(); coastalFoamTextures = buildCoastalFoamTextures(); wireUI(); renderPalette(); hydrateAuthorUI(); updateAccountUI(); if (els.assetCategory && !els.assetCategory.value) els.assetCategory.value = 'human'; refreshCategoryUI(); setupEditor(8, blankPixels(8), null); clearEditorHistory(); renderLibrary({ preserveScroll: false }); updateSelectedLabel(); hydrateVisualSettingsUI(); updateRotationStats(); await syncSharedWorld({ silent: true, reason: 'boot' }); startSharedSyncLoop(); scheduleFrame(); } function wireUI() { window.addEventListener('resize', () => { resizeCanvas(); render(); }); drawerScroller()?.addEventListener('scroll', () => { const body = drawerScroller(); libraryScrollTop = body?.scrollTop || 0; uiState.library.scroll.bodyTop = libraryScrollTop; }, { passive: true }); els.openEditor?.addEventListener('click', () => { clearWorldSelection(false); setDrawerOpen(true); }); els.openCreate?.addEventListener('click', () => { setTab('draw'); clearWorldSelection(false); setDrawerOpen(true); }); els.openCollection?.addEventListener('click', () => { setTab('library', { preserveScroll: true }); clearWorldSelection(false); setDrawerOpen(true); }); els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); els.tabs.forEach((button) => { button.addEventListener('click', () => setTab(button.dataset.tab, { preserveScroll: button.dataset.tab === 'library' })); }); drawerScroller()?.addEventListener('scroll', () => { if (activeTabName !== 'library') return; const top = drawerScroller()?.scrollTop; if (Number.isFinite(top)) libraryScrollTop = top; }, { passive: true }); els.authorName?.addEventListener('input', () => { const fallback = state.account?.id || 'Local Artist'; state.authorName = (els.authorName.value || fallback).trim() || fallback; if (state.account) state.account.name = state.authorName; saveState(); updateAccountUI(); renderLibrary(); }); els.accountPass?.addEventListener('input', () => { if (!state.account) return; state.account.password = (els.accountPass.value || '').trim(); saveState(); updateAccountUI(); }); els.createAccount?.addEventListener('click', () => createLocalAccount(false)); els.placeHereButton?.addEventListener('pointerdown', (event) => { event.preventDefault(); event.stopPropagation(); confirmPreviewPlacement(); }); els.placeHereButton?.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); if (placementPreview?.asset) confirmPreviewPlacement(); }); els.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; spriteCache.clear(); saveState(); render(); }); els.settingParticles?.addEventListener('change', () => { visualSettings().enableParticles = !!els.settingParticles.checked; if (!visualSettings().enableParticles) clearVisualEffectState(); saveState(); render(); }); els.settingDayNight?.addEventListener('change', () => { visualSettings().enableDayNight = !!els.settingDayNight.checked; spriteCache.clear(); saveState(); updateClock(); render(); }); els.displayLimit?.addEventListener('change', () => { const value = clampInt(els.displayLimit.value, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); visualSettings().localDisplayLimit = value; els.displayLimit.value = String(value); saveState(); renderLibrary(); render(); }); [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect'))); [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place'))); [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase'))); [els.zoomOut, els.drawerZoomOut].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom / 1.2))); [els.zoomIn, els.drawerZoomIn].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom * 1.2))); [els.resetView, els.drawerResetView].filter(Boolean).forEach((el) => el.addEventListener('click', () => resetView(true))); els.canvas.addEventListener('pointerdown', onWorldPointerDown); els.canvas.addEventListener('pointermove', onWorldPointerMove); els.canvas.addEventListener('pointerup', onWorldPointerUp); els.canvas.addEventListener('pointercancel', onWorldPointerUp); els.canvas.addEventListener('wheel', onWorldWheel, { passive: false }); els.canvas.addEventListener('contextmenu', (event) => { event.preventDefault(); if (suppressCanvasContextMenuOnce) { suppressCanvasContextMenuOnce = false; return; } if (mode === 'place' && placementPreview?.asset && placementPreview.x != null && placementPreview.y != null) { confirmPreviewPlacement(); return; } cancelPlacementPreview(false); clearWorldSelection(true); setMode('inspect'); }); document.addEventListener('contextmenu', onDocumentContextMenu); document.addEventListener('visibilitychange', onVisibilityChange); els.assetSize?.addEventListener('change', () => { const nextSize = clampDimension(els.assetSize.value, editorSize); resizeEditorCanvas(nextSize, nextSize); }); [els.assetWidth, els.assetHeight].filter(Boolean).forEach((input) => { input.addEventListener('change', () => { resizeEditorCanvas(clampDimension(els.assetWidth?.value, editorWidth), clampDimension(els.assetHeight?.value, editorHeight)); }); }); els.assetCategory.addEventListener('change', refreshCategoryUI); document.querySelectorAll('[data-static-kind]').forEach((button) => { button.addEventListener('click', () => { staticKind = button.dataset.staticKind; document.querySelectorAll('[data-static-kind]').forEach((b) => b.classList.toggle('active', b === button)); refreshCategoryUI(); }); }); document.querySelectorAll('[data-dynamic-kind]').forEach((button) => { button.addEventListener('click', () => { dynamicKind = button.dataset.dynamicKind; document.querySelectorAll('[data-dynamic-kind]').forEach((b) => b.classList.toggle('active', b === button)); refreshCategoryUI(); }); }); els.editRight.addEventListener('click', () => setEditingSide('right')); els.editLeft.addEventListener('click', () => setEditingSide('left')); els.frontRight?.addEventListener('click', () => setFrontSide('right')); els.frontLeft?.addEventListener('click', () => setFrontSide('left')); els.toolBrush.addEventListener('click', () => setPaintTool('brush')); els.toolErase.addEventListener('click', () => setPaintTool('erase')); els.toolFill?.addEventListener('click', () => setPaintTool('fill')); els.toolPick?.addEventListener('click', () => setPaintTool('pick')); els.toolLine?.addEventListener('click', () => setPaintTool('line')); els.toolRect?.addEventListener('click', () => setPaintTool('rect')); els.toolSelect?.addEventListener('click', () => setPaintTool('select')); els.toolLight.addEventListener('click', () => setPaintTool('light')); els.toolDoor.addEventListener('click', () => setPaintTool('door')); els.toolDepth?.addEventListener('click', () => setPaintTool('depth')); els.toolParticle?.addEventListener('click', () => { setPaintTool('particle'); enableParticleEffect(); }); els.particleDirection?.addEventListener('change', () => { const before = JSON.stringify(particleConfig); particleConfig = normalizeParticleConfig({ ...particleConfig, dir: els.particleDirection.value || 'up' }); if (particleConfig.enabled && JSON.stringify(particleConfig) !== before) markEditorChanged(); updateParticleUI(); renderPalette(); drawEditor(); }); els.particleUseSelection?.addEventListener('click', setParticleRangeFromSelection); els.particleClearRange?.addEventListener('click', clearParticleRange); els.depthHigh?.addEventListener('click', () => setDepthPaintMode(1)); els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1)); els.toggleAdvanced?.addEventListener('click', () => { setAdvancedDraw(!advancedDraw); }); els.clearPaint.addEventListener('click', async () => { const confirmed = await confirmWebsiteDialog({ title: 'Clear canvas', message: 'Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.', okText: 'Clear', danger: true }); if (!confirmed) return; commitEditorMutation(() => { editorPixels = blankPixels(editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; particlePixels = []; particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; editorSelection = null; return true; }); }); els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); els.flipVertical?.addEventListener('click', flipEditorVertical); els.outlinePaint?.addEventListener('click', applyEditorOutline); els.clearSelection?.addEventListener('click', () => clearEditorSelection(true)); els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0)); els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0)); els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1)); els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1)); els.undoPaint?.addEventListener('click', undoEditor); els.redoPaint?.addEventListener('click', redoEditor); window.addEventListener('keydown', onEditorKeyDown); els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); els.bubbleEdit?.addEventListener('click', () => editSelectedOriginal()); els.bubbleTeleport?.addEventListener('click', () => teleportToRemixSource()); els.bubbleMenu?.addEventListener('click', () => toggleBubbleMenu()); els.bubbleReport?.addEventListener('click', () => openReportDialog()); els.reportCancel?.addEventListener('click', () => closeReportDialog()); els.reportSubmit?.addEventListener('click', () => submitReportDialog()); els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); }); els.confirmCancel?.addEventListener('click', () => resolveConfirmDialog(false)); els.confirmOk?.addEventListener('click', () => resolveConfirmDialog(true)); els.confirmDialog?.addEventListener('click', (event) => { if (event.target === els.confirmDialog) resolveConfirmDialog(false); }); els.bubbleHide?.addEventListener('click', () => hideSelected()); els.paintCanvas.addEventListener('pointerdown', onPaintPointerDown); els.paintCanvas.addEventListener('pointermove', onPaintPointerMove); els.paintCanvas.addEventListener('pointerup', onPaintPointerUp); els.paintCanvas.addEventListener('pointercancel', onPaintPointerUp); els.paintCanvas.addEventListener('mousedown', onPaintMouseDown); window.addEventListener('mousemove', onPaintMouseMove); window.addEventListener('mouseup', onPaintMouseUp); els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false }); els.paintCanvas.addEventListener('click', onPaintClick); window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; finishEditorGesture(); }); els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault()); els.saveAsset.addEventListener('click', saveAssetFromEditor); els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); els.checkOnIsland?.addEventListener('click', checkCurrentEditorOnIsland); els.newAsset?.addEventListener('click', newAsset); els.showHiddenAssets?.addEventListener('click', () => { els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden; renderHiddenAssets(); }); } function resizeCanvas() { dpr = window.devicePixelRatio || 1; cw = Math.max(1, window.innerWidth); ch = Math.max(1, window.innerHeight); els.canvas.width = Math.floor(cw * dpr); els.canvas.height = Math.floor(ch * dpr); els.canvas.style.width = `${cw}px`; els.canvas.style.height = `${ch}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (!cursorScreen.active) cursorScreen = { x: cw / 2, y: ch / 2, active: false }; } function resetView(announce) { const bounds = terrainCache.bounds; view.zoom = Math.min(1.05, Math.max(MIN_ZOOM, Math.min(cw / bounds.w, ch / bounds.h) * 1.18)); view.x = cw / 2 - (bounds.x + bounds.w / 2) * view.zoom; view.y = ch / 2 - (bounds.y + bounds.h / 2) * view.zoom + 34; if (announce) toast('View reset.'); } function setDrawerOpen(open) { els.drawer.classList.toggle('open', open); els.openEditor.hidden = open || mode === 'place' || Boolean(placementPreview); if (open) ensureEditorHelpers(); if (!open) { selectedObject = null; cameraFollowSelected = false; updateSelectionBubble(performance.now()); } } function ensureEditorHelpers() { if (editorActions() || !ModuleLoader?.loadScript) return; ModuleLoader.loadScript('./js/editor-actions.js') .catch((error) => console.warn('Editor helper lazy load failed; using inline fallbacks.', error)); } function onDocumentContextMenu(event) { if (!els.drawer?.classList.contains('open')) return; if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return; event.preventDefault(); setDrawerOpen(false); } function drawerScroller() { return els.drawer?.querySelector('.drawerBody') || null; } function syncLibraryStateFromLegacy() { uiState.library.viewTab = libraryViewTab; uiState.library.search = librarySearch; uiState.library.filter = libraryFilter; uiState.library.scroll.bodyTop = Number.isFinite(libraryScrollTop) ? libraryScrollTop : 0; uiState.library.scroll.gridTop = Number.isFinite(libraryGridScrollTop) ? libraryGridScrollTop : 0; uiState.selectedAssetId = selectedAssetId || null; } function syncLegacyFromLibraryState() { libraryViewTab = uiState.library.viewTab || 'mine'; librarySearch = uiState.library.search || ''; libraryFilter = uiState.library.filter || 'all'; libraryScrollTop = Number(uiState.library.scroll?.bodyTop) || 0; libraryGridScrollTop = Number(uiState.library.scroll?.gridTop) || 0; libraryScrollFrameToken = Number(uiState.library.restoreToken) || 0; selectedAssetId = uiState.selectedAssetId || selectedAssetId || null; } function captureLibraryScrollState() { const body = drawerScroller(); const grid = els.assetList?.querySelector('.assetSectionGrid'); const bodyTop = Number.isFinite(body?.scrollTop) ? body.scrollTop : libraryScrollTop; const gridTop = Number.isFinite(grid?.scrollTop) ? grid.scrollTop : libraryGridScrollTop; uiState.library.scroll = { bodyTop: Math.max(0, Number(bodyTop) || 0), gridTop: Math.max(0, Number(gridTop) || 0) }; syncLegacyFromLibraryState(); return { ...uiState.library.scroll }; } function setTab(name, options = {}) { const keepScroll = Boolean(options.preserveScroll); const scrollState = keepScroll ? captureLibraryScrollState() : null; activeTabName = name; els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name)); els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`)); if (name !== 'draw' && advancedDraw) setAdvancedDraw(false, { redraw: false }); else els.drawer?.classList.toggle('advancedTools', name === 'draw' && advancedDraw); refreshCategoryUI(); if (keepScroll) restoreLibraryScrollState(scrollState); } function setAdvancedDraw(enabled, options = {}) { const redraw = options.redraw !== false; advancedDraw = Boolean(enabled); const selectedPaletteIndex = PALETTE.findIndex((entry) => entry.code === selectedColorCode); if (!advancedDraw && selectedPaletteIndex >= BASIC_PALETTE_COUNT) { selectedColorCode = nearestBasicPaletteCode(PALETTE_BY_CODE[selectedColorCode] || PALETTE_BY_CODE[DEFAULT_SELECTED_COLOR_CODE] || '#ffffff'); } toggleHidden(els.advancedToolGroup, !advancedDraw); toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced?.classList.toggle('active', advancedDraw); if (els.toggleAdvanced) els.toggleAdvanced.textContent = advancedDraw ? '- Advanced' : '+ Advanced'; els.drawer?.classList.toggle('advancedTools', activeTabName === 'draw' && advancedDraw); if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush'); updateParticleUI(); renderPalette(); if (redraw) drawEditor(); } function setMode(nextMode) { mode = nextMode; if (mode === 'place' || mode === 'erase') clearWorldSelection(false); const isInspect = mode === 'inspect'; const isPlace = mode === 'place'; const isErase = mode === 'erase'; [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.classList.toggle('active', isInspect)); [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.classList.toggle('active', isPlace)); [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.classList.toggle('active', isErase)); els.canvas.classList.toggle('placeCursor', isPlace); els.canvas.classList.toggle('eraseCursor', isErase); syncPlacementUi(); } function syncPlacementUi() { const hasPreview = Boolean(placementPreview); if (els.openEditor) els.openEditor.hidden = els.drawer?.classList.contains('open') || mode === 'place' || hasPreview; updatePlaceHereButton(); } function updatePlaceHereButton() { if (!els.placeHereButton) return; const ready = Boolean(mode === 'place' && placementPreview?.asset && placementPreview.x != null && placementPreview.y != null); els.placeHereButton.hidden = !ready; if (!ready) return; const x = Number(placementPreview.x); const y = Number(placementPreview.y); const pos = tileToWorld(x + .5, y + .5); pos.y -= getLiftAtCoord(x, y); const sx = pos.x * view.zoom + view.x; const sy = pos.y * view.zoom + view.y; els.placeHereButton.style.left = `${Math.round(clamp(sx, 96, cw - 96))}px`; els.placeHereButton.style.top = `${Math.round(clamp(sy - 64, 80, ch - 118))}px`; } function refreshCategoryUI() { els.drawer?.classList.toggle('advancedTools', activeTabName === 'draw' && advancedDraw); const role = currentRole(); const isStatic = roleToCategory(role) === 'static'; if (isStatic) staticKind = roleToSubtype(role); else dynamicKind = roleToSubtype(role); toggleHidden(els.staticKindWrap, true); toggleHidden(els.dynamicKindWrap, true); const showDynamicDrawControls = activeTabName === 'draw' && !isStatic; toggleHidden(els.sideSwitcher, true); toggleHidden(els.frontSideSwitcher, !showDynamicDrawControls); toggleHidden(els.staticSettingsPanel, !isStatic); toggleHidden(els.dynamicSettingsPanel, true); toggleHidden(els.advancedToolGroup, !advancedDraw); toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth' || paintTool === 'particle')) setPaintTool('brush'); if (!isStatic && paintTool === 'door') setPaintTool('brush'); if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush'); if (isStatic) editingSide = 'right'; if (isStatic) frontSide = 'right'; updateRoleHint(); updateSideButtons(); clampEditorView(); drawEditor(); updateParticleUI(); updateSettingsSummary(); } function updateRoleHint() { if (!els.roleHint) return; const role = currentRole(); const textMap = { human: 'Humans move by target direction. Draw as Right, or press Left if your canvas is left-facing so Save mirrors it.', animal: 'Animals move by target direction. Draw as Right, or press Left if your canvas is left-facing so Save mirrors it.', bird: 'Birds fly over terrain and seek nature. Draw as Right, or press Left if your canvas is left-facing so Save mirrors it.', nature: 'Nature attracts animals and birds. Static sprites are drawn at 2x scale.', fish: 'Fish swim on water tiles. Draw as right-facing, or use Left if your canvas is left-facing so Save mirrors it.', building: 'Buildings attract humans. Use Door to mark the entrance.', ship: 'Ships are static water objects. They float and emit ring ripples.', other: 'Other objects are neutral scenery and render at 2x scale.' }; els.roleHint.textContent = textMap[role] || textMap.other; } function updateSettingsSummary() { if (!els.settingsSummary) return; const role = currentRole(); if (roleToCategory(role) === 'static') { const lightCount = lightPixels.length; const maxLights = lightBudgetForArea(editorWidth, editorHeight); const lightText = lightCount ? `${lightCount}/${maxLights} lamp cell${lightCount === 1 ? '' : 's'}` : `no light (${maxLights} max)`; const particleText = particlePixels.length ? `particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : 'no particles'; const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}x${editorHeight} canvas / 2x static pixels / ${lightText} / ${particleText}${doorText}.`; } else { const particleText = particlePixels.length ? ` / particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : ''; const sideText = editingSide === 'left' ? 'canvas marked Left; Save mirrors it into canonical Right' : 'canvas marked canonical Right'; els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}x${editorHeight} canvas / ${sideText} / ${frontSide === 'left' ? 'front starts Left' : 'front starts Right'}${particleText}.`; } } function setPaintTool(tool) { paintTool = tool; [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolParticle, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, particle: els.toolParticle, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); const hints = { brush: 'Draw pixels with the selected palette color.', erase: 'Erase metadata first when Light/Depth/Particle exists on the cell; erase the pixel on the next click.', fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.', pick: 'Pick a color from the canvas and return to Draw.', line: 'Drag to draw a straight line. Right-click erases along the line.', rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', select: 'Left-click/drag to select pixels. Right-click clears the selection.', light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', particle: 'Particle emits from the sprite or selected range. Palette chooses color; direction is set below. Shift-click disables particles; right-click pans.', door: 'Click one pixel to mark a building door. Humans will enter near this point.', depth: 'Advanced: paint high or low depth. Use High/Low buttons; Shift/right-click clears.' }; els.editHint.textContent = hints[tool] || hints.brush; [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); toggleHidden(els.particleDirectionWrap, !advancedDraw || tool !== 'particle'); updateParticleUI(); } function setDepthPaintMode(mode) { depthPaintMode = mode === -1 ? -1 : 1; [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); if (paintTool !== 'depth') setPaintTool('depth'); const label = depthPaintMode > 0 ? 'high' : 'low'; if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Shift/right-click clears depth.`; } function setEditingSide(side) { if (roleToCategory(currentRole()) !== 'dynamic') return; editingSide = side === 'left' ? 'left' : 'right'; updateSideButtons(); if (els.editHint) { els.editHint.textContent = editingSide === 'left' ? 'Canvas is marked as left-facing. It will be mirrored into the canonical right-facing sprite on save.' : 'Canvas is marked as the canonical right-facing sprite. Movement uses this for rightward travel.'; } drawEditor(); } function setFrontSide(side) { if (roleToCategory(currentRole()) !== 'dynamic') return; frontSide = side === 'left' ? 'left' : 'right'; updateSideButtons(); if (els.editHint) els.editHint.textContent = `Movable sprite will appear facing ${frontSide} when first placed.`; spriteCache.clear(); drawEditor(); render(); } function updateSideButtons() { if (els.editRight) els.editRight.classList.toggle('active', editingSide === 'right'); if (els.editLeft) els.editLeft.classList.toggle('active', editingSide === 'left'); if (els.frontRight) els.frontRight.classList.toggle('active', frontSide === 'right'); if (els.frontLeft) els.frontLeft.classList.toggle('active', frontSide === 'left'); updateSettingsSummary(); } function onWorldPointerDown(event) { event.preventDefault(); if (isPlaceHereEvent(event)) { confirmPreviewPlacement(); return; } const downPos = getCanvasPoint(event); cursorScreen = { x: downPos.x, y: downPos.y, active: true }; if (event.button === 2) { if (mode === 'place' && placementPreview?.asset && placementPreview.x != null && placementPreview.y != null) { suppressCanvasContextMenuOnce = true; confirmPreviewPlacement(); } else { cancelPlacementPreview(false); clearWorldSelection(true); setMode('inspect'); } pointer.down = false; pointer.id = null; return; } els.canvas.setPointerCapture(event.pointerId); pointer = { down: true, id: event.pointerId, startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, dragging: false, downTime: performance.now(), button: event.button || 0 }; } function onWorldPointerMove(event) { const pos = getCanvasPoint(event); cursorScreen = { x: pos.x, y: pos.y, active: true }; hoverTile = screenToTile(pos.x, pos.y); updateTileInfo(); if (!pointer.down || pointer.id !== event.pointerId) return; const dx = event.clientX - pointer.lastX; const dy = event.clientY - pointer.lastY; const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); if (total > 8) { pointer.dragging = true; els.canvas.classList.add('dragging'); } if (pointer.dragging) { cameraFollowSelected = false; view.x += dx; view.y += dy; } pointer.lastX = event.clientX; pointer.lastY = event.clientY; } function onWorldPointerUp(event) { if (isPlaceHereEvent(event)) { confirmPreviewPlacement(); return; } if (!pointer.down || pointer.id !== event.pointerId) return; els.canvas.classList.remove('dragging'); const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); const elapsed = performance.now() - pointer.downTime; const wasClick = total < 6 && elapsed < 600; const pos = getCanvasPoint(event); cursorScreen = { x: pos.x, y: pos.y, active: true }; const tile = screenToTile(pos.x, pos.y); if (wasClick) { const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now()); if (mode === 'place' && tile) placeSelected(tile.x, tile.y); else if (mode === 'erase') { if (pickedObject) eraseObject(pickedObject.kind, pickedObject.id); else if (tile) eraseAt(tile.x, tile.y); } else if (pickedObject) { selectWorldObject(pickedObject.kind, pickedObject.id, pickedObject.assetId, performance.now()); const asset = findAsset(pickedObject.assetId); toast(asset ? `Selected ${asset.name}.` : 'Selected object.'); } else if (tile) inspectAt(tile.x, tile.y); } pointer.down = false; pointer.id = null; pointer.dragging = false; } function isPlaceHereEvent(event) { if (mode !== 'place' || !placementPreview?.asset || !els.placeHereButton || els.placeHereButton.hidden) return false; const rect = els.placeHereButton.getBoundingClientRect(); return event.clientX >= rect.left - 8 && event.clientX <= rect.right + 8 && event.clientY >= rect.top - 8 && event.clientY <= rect.bottom + 8; } function onWorldWheel(event) { event.preventDefault(); cameraFollowSelected = false; const pos = getCanvasPoint(event); const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13; zoomAt(pos.x, pos.y, view.zoom * factor); } function zoomAt(screenX, screenY, nextZoom) { nextZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM); const worldX = (screenX - view.x) / view.zoom; const worldY = (screenY - view.y) / view.zoom; view.zoom = nextZoom; view.x = screenX - worldX * view.zoom; view.y = screenY - worldY * view.zoom; } function getCanvasPoint(event) { const rect = els.canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; } function screenToTile(screenX, screenY) { const worldX = (screenX - view.x) / view.zoom; const worldY = (screenY - view.y) / view.zoom; const localX = worldX - ORIGIN_X; const localY = worldY - ORIGIN_Y; const a = localX / (TILE_W / 2); const b = localY / (TILE_H / 2); const baseX = Math.floor((a + b) / 2); const baseY = Math.floor((b - a) / 2); let best = null; let bestScore = -Infinity; for (let ty = baseY - 2; ty <= baseY + 2; ty++) { for (let tx = baseX - 2; tx <= baseX + 2; tx++) { if (tx < 0 || ty < 0 || tx >= WORLD_W || ty >= WORLD_H) continue; const tile = world.get(tx, ty); const center = tileToWorld(tx, ty); const lift = getTileLift(tile); const nx = Math.abs(worldX - center.x) / (TILE_W / 2); const ny = Math.abs(worldY - (center.y + TILE_H / 2 - lift)) / (TILE_H / 2); if (nx + ny <= 1) { const score = lift * 100 + (tx + ty); if (score > bestScore) { bestScore = score; best = { x: tx, y: ty, tile }; } } } } return best; } function tileToWorld(x, y) { return { x: (x - y) * TILE_W / 2 + ORIGIN_X, y: (x + y) * TILE_H / 2 + ORIGIN_Y }; } function worldToTileFloat(worldX, worldY) { const a = (worldX - ORIGIN_X) / (TILE_W / 2); const b = (worldY - ORIGIN_Y) / (TILE_H / 2); return { x: (a + b) / 2, y: (b - a) / 2 }; } function tileKey(x, y) { return `${x},${y}`; } function makeEmptyWorldIndex() { return StateIndex?.build ? StateIndex.build({ assets: [], placed: [], dynamicSummons: [] }) : { assetById: new Map(), staticById: new Map(), dynamicById: new Map(), objectsById: new Map(), objectIdsByAssetId: new Map(), placedByTile: new Map(), dynamicByHomeTile: new Map() }; } function pushIndexBucket(map, key, item) { const bucket = map.get(key); if (bucket) bucket.push(item); else map.set(key, [item]); } function rebuildWorldIndex() { if (StateIndex?.build) { worldIndex = StateIndex.build(state); return; } const next = makeEmptyWorldIndex(); for (const asset of state.assets || []) { if (asset?.id) next.assetById.set(asset.id, asset); } for (const placed of state.placed || []) { next.staticById.set(placed.id, placed); next.objectsById.set(placed.id, { kind: 'static', object: placed }); pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed); pushIndexBucket(next.objectIdsByAssetId, placed.assetId, placed.id); } for (const summon of state.dynamicSummons || []) { next.dynamicById.set(summon.id, summon); next.objectsById.set(summon.id, { kind: 'dynamic', object: summon }); pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon); pushIndexBucket(next.objectIdsByAssetId, summon.assetId, summon.id); } worldIndex = next; } function getPlacedAtTile(x, y) { return worldIndex.placedByTile.get(tileKey(x, y)) || []; } function getDynamicHomesAtTile(x, y) { return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || []; } function getLocalDisplayLimit() { const settings = visualSettings(); return clampInt(settings.localDisplayLimit, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); } function getObjectRecord(kind, objectId) { if (kind === 'static') return worldIndex.staticById?.get(objectId) || state.placed.find((p) => p.id === objectId) || null; return worldIndex.dynamicById?.get(objectId) || state.dynamicSummons.find((p) => p.id === objectId) || null; } function getObjectPublicAt(kind, object) { return RotationPolicy?.objectPublicAt ? RotationPolicy.objectPublicAt(object) : Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now()); } function isPermanentlyHiddenObject(object) { return object?.status === 'permanent_hidden' || object?.status === 'violation_hidden' || object?.permanentHidden === true; } function isModeratedAssetHidden(asset) { if (!asset) return false; if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true; const objectIds = worldIndex.objectIdsByAssetId?.get(asset.id) || []; for (const objectId of objectIds) { const entry = worldIndex.objectsById?.get(objectId); if (entry?.object && isPermanentlyHiddenObject(entry.object) && entry.object.hiddenReason === 'moderation_violation') return true; } return false; } function isServerSuppressedObject(object) { return ['archived', 'permanent_hidden', 'violation_hidden'].includes(object?.status); } function getObjectRotationEntry(kind, object, baseIndex = 0) { const votes = getObjectVoteCounts(object?.id); if (RotationPolicy?.entry) return RotationPolicy.entry(kind, object, baseIndex, votes, PHASE5_GUARDRAILS); const rawUp = Number(votes.up) || 0; const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap); const down = Number(votes.down) || 0; return { kind, object, id: object?.id, assetId: object?.assetId, publicAt: getObjectPublicAt(kind, object), baseIndex, up, rawUp, down, effectiveSlot: baseIndex + up * PHASE5_GUARDRAILS.upvoteDelaySlots - down * PHASE5_GUARDRAILS.downvoteAdvanceSlots }; } function getRotationEntries(includeLocalHidden = false) { const entries = []; for (const placed of state.placed || []) { const asset = findAsset(placed.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || isServerSuppressedObject(placed)) continue; if (!includeLocalHidden && state.hiddenObjects?.[placed.id]) continue; entries.push({ kind: 'static', object: placed }); } for (const summon of state.dynamicSummons || []) { const asset = findAsset(summon.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || isServerSuppressedObject(summon)) continue; if (!includeLocalHidden && state.hiddenObjects?.[summon.id]) continue; entries.push({ kind: 'dynamic', object: summon }); } entries.sort((a, b) => getObjectPublicAt(a.kind, a.object) - getObjectPublicAt(b.kind, b.object) || String(a.object.id).localeCompare(String(b.object.id))); return entries.map((entry, index) => getObjectRotationEntry(entry.kind, entry.object, index)); } function seededScore(id, salt = '') { if (RotationPolicy?.seededScore) return RotationPolicy.seededScore(id, salt, state.lastRotationAt || Date.now()); const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`); return parseInt(hash.slice(0, 8), 16) / 0xffffffff; } function getIslandDisplayBuckets() { const entries = getRotationEntries(false); const localLimit = getLocalDisplayLimit(); if (RotationPolicy?.buckets) return RotationPolicy.buckets(entries, localLimit, PHASE5_GUARDRAILS, state.lastRotationAt || Date.now()); const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit); const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap)); const newest = entries .slice() .sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id))) .slice(0, newCap); const newestIds = new Set(newest.map((entry) => entry.id)); const revivalPool = entries.filter((entry) => !newestIds.has(entry.id)); const revival = revivalPool .slice() .sort((a, b) => seededScore(b.id, 'revival') - seededScore(a.id, 'revival') || b.up - a.up || String(b.id).localeCompare(String(a.id))) .slice(0, revivalCap); return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((entry) => entry.id)) }; } function getVisibleRotationIdSet() { return getIslandDisplayBuckets().visibleIds; } function isWorldObjectVisibleByRotation(kind, objectId) { const object = getObjectRecord(kind, objectId); if (!object || isServerSuppressedObject(object) || state.hiddenObjects?.[objectId]) return false; return getVisibleRotationIdSet().has(objectId); } function updateRotationStats() { if (!els.rotationStats) return; const buckets = getIslandDisplayBuckets(); const entries = buckets.entries; const visible = buckets.visibleIds.size; const hiddenByRotation = Math.max(0, entries.length - visible); const limit = getLocalDisplayLimit(); const publish = getPublishQuotaStatus(); const quotaText = publish.accountRequired ? 'account required to publish' : `publish quota ${publish.used}/${publish.limit} this hour`; els.rotationStats.textContent = `Island exhibition ${visible}/${entries.length} objects / newest ${buckets.newest.length}/150 / revival ${buckets.revival.length}/100 / local cap ${limit} / rotated out ${hiddenByRotation} / ${quotaText}.`; updateAccountUI(); } function getAccountAgeMs(now = Date.now()) { return state.account?.createdAt ? now - Number(state.account.createdAt) : 0; } function currentPublishLimit(now = Date.now()) { if (!state.account?.createdAt) return 0; if (RotationPolicy?.publishLimit) return RotationPolicy.publishLimit(state.account, now, PHASE5_GUARDRAILS); return getAccountAgeMs(now) < 24 * 60 * 60 * 1000 ? PHASE5_GUARDRAILS.publishLimitFirstDay : PHASE5_GUARDRAILS.publishLimitTrusted; } function getPublishQuotaStatus(now = Date.now()) { state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; state.pendingPublishLog = Array.isArray(state.pendingPublishLog) ? state.pendingPublishLog : []; const author = currentVoterKey(); const windowMs = 60 * 60 * 1000; state.publishLog = state.publishLog.filter((entry) => now - Number(entry.at || 0) < windowMs * 24); state.pendingPublishLog = state.pendingPublishLog.filter((entry) => entry.status === 'pending' && now - Number(entry.at || 0) < windowMs * 24); const serverConfirmed = [...(state.placed || []), ...(state.dynamicSummons || [])].filter((object) => { const owner = normalizeOwnerAccountId(object.ownerAccountId); return owner === author && now - Number(object.publishedAt || object.placedAt || object.createdAt || 0) < windowMs; }).length; const localConfirmed = state.publishLog.filter((entry) => entry.author === author && now - Number(entry.at || 0) < windowMs).length; const pending = state.pendingPublishLog.filter((entry) => entry.author === author && now - Number(entry.at || 0) < windowMs).length; const used = (isSharedWorld() ? serverConfirmed : localConfirmed) + pending; const limit = currentPublishLimit(now); return { used, pending, limit, remaining: Math.max(0, limit - used), accountRequired: !state.account?.createdAt }; } function canPublishObject(action = 'publish') { if (!state.account?.createdAt) ensureLocalAccount(action === 'publish' ? 'publish' : 'republish'); const quota = getPublishQuotaStatus(); if (quota.accountRequired) { toast('Create a local account before publishing works to the island.'); updateAccountUI(); return false; } if (quota.remaining <= 0) { toast(`Publish limit reached: ${quota.limit} public placements per hour.`); updatePublishQuotaUI(); return false; } return true; } function recordObjectPublish(kind, object, action = 'publish', commandId = '') { if (!object) return; const now = Date.now(); object.publishedAt = now; object.status = 'active'; state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; state.publishLog.push({ at: now, author: currentVoterKey(), kind, objectId: object.id, assetId: object.assetId, action, commandId }); state.publishLog = state.publishLog.slice(-300); updatePublishQuotaUI(); } function recordPendingSharedPublish(kind, object, commandId = '') { if (!object || !commandId) return; state.pendingPublishLog = Array.isArray(state.pendingPublishLog) ? state.pendingPublishLog : []; state.pendingPublishLog.push({ at: Date.now(), author: currentVoterKey(), kind, objectId: object.id, assetId: object.assetId, commandId, status: 'pending' }); state.pendingPublishLog = state.pendingPublishLog.slice(-300); updatePublishQuotaUI(); } function isSharedWorld() { return true; } function currentAccountId() { return String(state.account?.id || '').trim(); } function normalizeOwnerAccountId(value, fallback = '') { return String(value || fallback || '').trim(); } function ensureWorldProtectionState(target = state) { target.worldMode = 'shared'; target.pendingPublishLog = Array.isArray(target.pendingPublishLog) ? target.pendingPublishLog.slice(-300) : []; target.serverSync = { lastServerEventId: target.serverSync?.lastServerEventId || null, authority: { ...DEFAULT_SERVER_AUTHORITY, ...(target.serverSync?.authority || {}) }, clock: target.serverSync?.clock && typeof target.serverSync.clock === 'object' ? { ...target.serverSync.clock } : { worldTimeMs: Date.now(), syncedAt: Date.now() }, dynamicTargets: target.serverSync?.dynamicTargets && typeof target.serverSync.dynamicTargets === 'object' ? { ...target.serverSync.dynamicTargets } : {}, pendingObjectVisuals: target.serverSync?.pendingObjectVisuals && typeof target.serverSync.pendingObjectVisuals === 'object' ? { ...target.serverSync.pendingObjectVisuals } : {} }; target.tombstones = { assets: target.tombstones?.assets && typeof target.tombstones.assets === 'object' ? target.tombstones.assets : {}, objects: target.tombstones?.objects && typeof target.tombstones.objects === 'object' ? target.tombstones.objects : {} }; target.deletedSeedAssetNames = Array.isArray(target.deletedSeedAssetNames) ? [...new Set(target.deletedSeedAssetNames.filter(Boolean).map(String))] : []; return target; } function isAssetTombstoned(assetId, version = 0) { const tombstone = state.tombstones?.assets?.[assetId]; return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0)); } function isObjectTombstoned(objectId, version = 0) { const tombstone = state.tombstones?.objects?.[objectId]; return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0)); } function getObjectOwner(kind, object) { if (!object) return ''; return normalizeOwnerAccountId(object.ownerAccountId, findAsset(object.assetId)?.ownerAccountId || ''); } function canCurrentAccountModifyObject(kind, object, showToast = true) { if (!isSharedWorld()) { const allowed = isCurrentUserAsset(findAsset(object?.assetId)); if (!allowed && showToast) toast('Only the owner can move or delete this island object.'); return allowed; } const actor = currentAccountId(); if (!actor) { if (showToast) toast('Shared worlds require an account before changing island objects.'); return false; } const owner = getObjectOwner(kind, object); if (owner && owner === actor) return true; if (showToast) toast('Only the owner can move or delete this island object.'); return false; } function removeObjectLocally(kind, objectId) { if (!objectId) return null; const list = kind === 'dynamic' ? state.dynamicSummons : state.placed; const index = list.findIndex((item) => item.id === objectId); if (index < 0) return null; const [object] = list.splice(index, 1); ensureWorldProtectionState(); state.tombstones.objects[objectId] = { id: objectId, assetId: object.assetId, deletedAt: Date.now(), deletedBy: currentAccountId() || 'local', ownerAccountId: normalizeOwnerAccountId(object.ownerAccountId), version: Number(object.version) || 1 }; delete state.objectVotes?.[objectId]; delete state.hiddenObjects?.[objectId]; state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== objectId); if (selectedObject?.id === objectId) selectedObject = null; rebuildWorldIndex(); if (kind === 'dynamic') hydrateRuntime(); saveState(); updateSelectionBubble(performance.now()); render(); return object; } function canCurrentAccountDeleteAsset(asset, showToast = true) { if (!isCurrentUserAsset(asset)) { if (showToast) toast('Only the owner can delete this asset.'); return false; } if (!isSharedWorld()) return true; const actor = currentAccountId(); if (!actor) { if (showToast) toast('Shared worlds require an account before deleting assets.'); return false; } const owner = normalizeOwnerAccountId(asset?.ownerAccountId); if (owner && owner === actor) return true; if (showToast) toast('Only the owner can delete this asset in a shared world.'); return false; } function isCurrentUserAsset(asset) { if (!asset) return false; if (!isSharedWorld()) { const owner = normalizeOwnerAccountId(asset.ownerAccountId); if (owner) return owner === currentAccountId(); return (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); } return normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId(); } function displayAssetAuthor(asset) { if (!asset) return state.authorName || 'Local Artist'; return asset.author || state.authorName || 'Local Artist'; } function makeSharedCommand(type, payload = {}) { return { id: `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, actorAccountId: currentAccountId(), createdAt: Date.now(), ...payload }; } function queueSharedCommand(command, options = {}) { if (!command) return; ensureWorldProtectionState(); const cachePromise = Phase2Sync?.cacheOutboxCommand?.(command); const commandObject = command.object?.id && command.object?.assetId ? command.object : null; if (commandObject) { const visualKind = command.kind === 'dynamic' ? 'dynamic' : 'static'; const existing = visualKind === 'dynamic' ? state.dynamicSummons.find((item) => item.id === commandObject.id) : state.placed.find((item) => item.id === commandObject.id); const toX = visualKind === 'dynamic' ? Number(commandObject.homeX) : Number(commandObject.x); const toY = visualKind === 'dynamic' ? Number(commandObject.homeY) : Number(commandObject.y); state.serverSync.pendingObjectVisuals[commandObject.id] = { status: 'pending', kind: visualKind, assetId: commandObject.assetId, object: { ...commandObject }, fromX: Number(existing ? (visualKind === 'dynamic' ? existing.homeX : existing.x) : toX), fromY: Number(existing ? (visualKind === 'dynamic' ? existing.homeY : existing.y) : toY), toX, toY, createdAt: command.createdAt || Date.now(), expiresAt: 0, expirePending: false, commandId: command.id }; } saveState(); if (options.toast !== false) toast('Change queued for server validation.'); if (cachePromise?.then) { cachePromise .then(() => syncSharedWorld({ silent: true, reason: 'queue' })) .catch((error) => console.warn('Phase 2 outbox cache failed.', error)); } else setTimeout(() => syncSharedWorld({ silent: true, reason: 'queue' }), 0); } function sharedApiUrl(action) { return `${SHARED_API_ENDPOINT}?action=${encodeURIComponent(action)}`; } async function requestSharedApi(action, options = {}) { const response = await fetch(sharedApiUrl(action), { credentials: 'same-origin', cache: 'no-store', ...options, headers: { ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(options.headers || {}) } }); const text = await response.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (error) { throw new Error(`invalid_json_response:${response.status}`); } if (!response.ok || !data?.ok) { const message = data?.error || `http_${response.status}`; const error = new Error(message); error.payload = data; error.status = response.status; throw error; } return data; } function prunePendingVisualsByCommandIds(commandIds) { const ids = new Set(Array.from(commandIds || []).filter(Boolean)); if (!ids.size) return; ensureWorldProtectionState(); for (const [objectId, entry] of Object.entries(state.serverSync.pendingObjectVisuals || {})) { if (ids.has(entry?.commandId)) delete state.serverSync.pendingObjectVisuals[objectId]; } } function markPendingVisualsFailed(rejections) { const items = Array.isArray(rejections) ? rejections : []; if (!items.length) return; ensureWorldProtectionState(); const byCommandId = new Map(items.map((item) => [item?.id, item]).filter(([id]) => id)); for (const entry of Object.values(state.serverSync.pendingObjectVisuals || {})) { const rejection = byCommandId.get(entry?.commandId); if (!rejection) continue; entry.status = 'failed'; entry.error = rejection.error || 'server_rejected'; entry.failedAt = Date.now(); entry.expirePending = false; entry.expiresAt = 0; if (entry.object) entry.object.publishFailed = true; } } function prunePendingVisualsResolvedBySnapshot() { ensureWorldProtectionState(); const serverObjectIds = new Set([...(state.placed || []), ...(state.dynamicSummons || [])].map((item) => item.id).filter(Boolean)); for (const objectId of Object.keys(state.serverSync.pendingObjectVisuals || {})) { if (serverObjectIds.has(objectId)) delete state.serverSync.pendingObjectVisuals[objectId]; } } function removePublishLogForCommandIds(commandIds) { const ids = new Set(Array.from(commandIds || []).filter(Boolean)); if (!ids.size) return; if (Array.isArray(state.publishLog)) state.publishLog = state.publishLog.filter((entry) => !ids.has(entry.commandId)); if (Array.isArray(state.pendingPublishLog)) state.pendingPublishLog = state.pendingPublishLog.filter((entry) => !ids.has(entry.commandId)); updatePublishQuotaUI(); } function describeSharedRejection(error) { const code = String(error || 'server_rejected'); if (code === 'publish_limit_reached') return 'Publish limit reached. This placement was not published.'; if (code === 'world_object_limit_reached') return 'Island object limit reached. This placement was not published.'; if (code === 'asset_owner_required') return 'Only the asset owner can publish this work. Use Remix to make your own copy.'; if (code === 'object_owner_required') return 'Only the owner can move or delete that island object.'; if (code === 'invalid_account_password') return 'Account password did not match the server record.'; return `Server rejected a queued change: ${code}`; } function handleSharedCommandResponse(data, options = {}) { const applied = Array.isArray(data?.appliedCommandIds) ? data.appliedCommandIds.filter(Boolean) : []; const rejected = Array.isArray(data?.rejectedCommands) ? data.rejectedCommands.filter(Boolean) : []; const rejectedIds = rejected.map((item) => item?.id).filter(Boolean); const finishedIds = [...applied, ...rejectedIds]; if (finishedIds.length) { Phase2Sync?.deleteOutboxCommands?.(finishedIds).catch((error) => console.warn('Phase 2 outbox delete failed.', error)); } prunePendingVisualsByCommandIds(applied); markPendingVisualsFailed(rejected); removePublishLogForCommandIds(finishedIds); if (rejected.length && options.silent !== true) toast(`${describeSharedRejection(rejected[0]?.error)} Try publishing again or choose a new tile.`); else if (rejected.length) console.warn(describeSharedRejection(rejected[0]?.error), rejected); } function serverAssetMerge(snapshotAssets) { const serverAssets = (Array.isArray(snapshotAssets) ? snapshotAssets : []).map(normalizeAsset); const serverIds = new Set(serverAssets.map((asset) => asset.id)); const actor = currentAccountId(); const localOnly = (state.assets || []).filter((asset) => { if (!asset?.id || serverIds.has(asset.id)) return false; const owner = normalizeOwnerAccountId(asset.ownerAccountId); if (owner === 'island-team') return false; return owner === actor || (!owner && (asset.author || '') === (state.authorName || 'Local Artist')); }); return [...localOnly, ...serverAssets]; } function applySharedSnapshot(snapshot) { if (!snapshot?.ok) return false; const previousSelectedAssetId = selectedAssetId; const pendingVisuals = state.serverSync?.pendingObjectVisuals || {}; const merged = mergeDefaultGallery({ ...state, schema: SAVE_SCHEMA, worldMode: 'shared', assets: serverAssetMerge(snapshot.assets), placed: Array.isArray(snapshot.placed) ? snapshot.placed : [], dynamicSummons: Array.isArray(snapshot.dynamicSummons) ? snapshot.dynamicSummons : [], objectVotes: snapshot.objectVotes || {}, assetVotes: snapshot.assetVotes || {}, moderationReports: normalizeModerationReports(snapshot.moderationReports || []), tombstones: snapshot.tombstones || { assets: {}, objects: {} }, serverSync: { ...sanitizeManifestServerSync(state.serverSync || {}), lastServerEventId: snapshot.lastEventId || state.serverSync?.lastServerEventId || null, authority: { ...DEFAULT_SERVER_AUTHORITY, ...(snapshot.authority || {}) }, clock: { ...(state.serverSync?.clock || {}), worldTimeMs: Number(snapshot.serverNow) || Date.now(), syncedAt: Date.now(), dayMs: Number(state.serverSync?.clock?.dayMs) || DAY_MS }, pendingObjectVisuals: pendingVisuals } }); state = normalizeState(merged); prunePendingVisualsResolvedBySnapshot(); if (previousSelectedAssetId && state.assets.some((asset) => asset.id === previousSelectedAssetId)) selectedAssetId = previousSelectedAssetId; else selectedAssetId = state.assets[0]?.id ?? null; rebuildWorldIndex(); hydrateRuntime(); spriteCache.clear(); saveState(); renderLibrary({ preserveScroll: true }); updateSelectedLabel(); updateRotationStats(); render(); return true; } async function syncSharedWorld(options = {}) { if (sharedSyncInFlight || document.hidden) return false; if (!globalThis.fetch) return false; sharedSyncInFlight = true; try { let data = null; const commands = Phase2Sync?.readOutboxCommands ? await Phase2Sync.readOutboxCommands(300) : []; if (commands.length && state.account?.id && state.account?.password) { data = await requestSharedApi('commands', { method: 'POST', body: JSON.stringify({ account: state.account, commands }) }); handleSharedCommandResponse(data, options); if (data.snapshot) applySharedSnapshot(data.snapshot); } else { data = await requestSharedApi('snapshot'); applySharedSnapshot(data); } sharedApiAvailable = true; lastSharedSyncAt = Date.now(); return true; } catch (error) { if (sharedApiAvailable !== false) console.warn('Shared API unavailable; waiting for server-backed shared state.', error); sharedApiAvailable = false; ensureWorldProtectionState(); return false; } finally { sharedSyncInFlight = false; } } function startSharedSyncLoop() { if (sharedSyncTimer) clearInterval(sharedSyncTimer); sharedSyncTimer = setInterval(() => syncSharedWorld({ silent: true, reason: 'timer' }), SHARED_SYNC_INTERVAL_MS); } function shouldHideUnderPlacementPreview(assetId, x, y) { return false; } function getDrawableItems(time = performance.now(), viewport = null) { const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); const items = []; const pendingVisuals = getPendingSharedVisuals(); const pendingVisualIds = new Set(pendingVisuals.map((entry) => String(entry.object?.id || ''))); for (const placed of state.placed) { if (pendingVisualIds.has(String(placed.id || ''))) continue; const asset = findAsset(placed.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id] || isPermanentlyHiddenObject(placed) || !isWorldObjectVisibleByRotation('static', placed.id)) continue; if (shouldHideUnderPlacementPreview(asset.id, placed.x + .5, placed.y + .5)) continue; const itemX = placed.x + .5; const itemY = placed.y + .5; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed }); } for (const runtime of dynamicRuntime) { if (pendingVisualIds.has(String(runtime.id || ''))) continue; const asset = findAsset(runtime.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id] || isPermanentlyHiddenObject(runtime) || !isWorldObjectVisibleByRotation('dynamic', runtime.id)) continue; if (shouldHideUnderPlacementPreview(asset.id, runtime.x, runtime.y)) continue; if (time < runtime.hiddenUntil) continue; if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); } for (const pending of pendingVisuals) { const asset = findAsset(pending.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id]) continue; const source = pending.object || {}; const targetX = Number.isFinite(Number(pending.toX)) ? Number(pending.toX) : (pending.kind === 'dynamic' ? Number(source.homeX) : Number(source.x)); const targetY = Number.isFinite(Number(pending.toY)) ? Number(pending.toY) : (pending.kind === 'dynamic' ? Number(source.homeY) : Number(source.y)); const startX = Number.isFinite(Number(pending.fromX)) ? Number(pending.fromX) : targetX; const startY = Number.isFinite(Number(pending.fromY)) ? Number(pending.fromY) : targetY; const t = clamp((Date.now() - Number(pending.createdAt || Date.now())) / 520, 0, 1); const eased = 1 - Math.pow(1 - t, 3); const itemX = lerp(startX, targetX, eased) + .5; const itemY = lerp(startY, targetY, eased) + .5; if (!Number.isFinite(itemX) || !Number.isFinite(itemY)) continue; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; const failed = pending.status === 'failed'; items.push({ kind: pending.kind, asset, x: itemX, y: itemY, source: { ...source, pending: !failed, failed }, pending: !failed, failed }); } if (placementPreview?.asset && placementPreview.x != null && placementPreview.y != null) { const asset = placementPreview.asset; items.push({ kind: asset.category === 'dynamic' ? 'dynamic' : 'static', asset, x: placementPreview.x + .5, y: placementPreview.y + .5, source: { id: 'placement-preview', preview: true, selectedAt: placementPreview.selectedAt || performance.now() }, preview: true }); } items.sort(drawOrderCompare); return items; } function getObjectSortBottom(item) { const pos = tileToWorld(item.x, item.y); const asset = item.asset || {}; if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(item.x, item.y); const anchorY = asset.category === 'static' ? 0 : TILE_H / 2; return pos.y + anchorY; } function drawOrderCompare(a, b) { const bottomDelta = getObjectSortBottom(a) - getObjectSortBottom(b); if (Math.abs(bottomDelta) > 0.01) return bottomDelta; return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || '')); } function getViewportWorldRect(margin = 0) { const invZoom = 1 / Math.max(0.0001, view.zoom); return { left: -view.x * invZoom - margin, top: -view.y * invZoom - margin, right: (cw - view.x) * invZoom + margin, bottom: (ch - view.y) * invZoom + margin }; } function isApproxVisible(asset, x, y, rect) { if (!rect) return true; const pos = tileToWorld(x, y); if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y); const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const w = assetWidth(asset) * scale + 96; const h = assetHeight(asset) * scale + 128; return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom; } function pickObjectAtScreen(screenX, screenY, time = performance.now()) { const worldX = (screenX - view.x) / view.zoom; const worldY = (screenY - view.y) / view.zoom; const items = getDrawableItems(time, getViewportWorldRect(256)); for (let i = items.length - 1; i >= 0; i--) { const item = items[i]; const info = getSpriteDrawInfo(item, time, false); if (isSpritePixelHit(item.asset, info, worldX, worldY)) { return { kind: item.kind, id: item.source.id, assetId: item.asset.id }; } } return null; } function isSpritePixelHit(asset, info, worldX, worldY) { const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; let localX; let localY; if (info.angle) { const pivotX = info.drawX + info.sprite.width / 2; const pivotY = info.drawY + info.sprite.height * 0.8; const dx = worldX - pivotX; const dy = worldY - pivotY; const cos = Math.cos(-info.angle); const sin = Math.sin(-info.angle); localX = dx * cos - dy * sin + info.sprite.width / 2; localY = dx * sin + dy * cos + info.sprite.height * 0.8; } else { localX = worldX - info.drawX; localY = worldY - info.drawY; } if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false; // Small works at 16x6 or below are hard to click. Use the whole sprite box // for them, including transparent cells, while larger works still use opaque pixels. const aw = assetWidth(asset); const ah = assetHeight(asset); if (Math.max(aw, ah) <= 16) return true; const px = Math.floor(localX / scale); const py = Math.floor(localY / scale); if (px < 0 || py < 0 || px >= aw || py >= ah) return false; const pixels = getAssetPixels(asset, info.side || 'right'); return Boolean(pixels[py * aw + px]); } function updateTileInfo() { if (!els.tileInfo) return; if (!hoverTile) { els.tileInfo.textContent = 'Move over the map.'; return; } const tile = world.get(hoverTile.x, hoverTile.y); const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).filter((p) => isWorldObjectVisibleByRotation('static', p.id)).length; const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).filter((p) => isWorldObjectVisibleByRotation('dynamic', p.id)).length; els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`; } function clearWorldSelection(announce = false) { const hadSelection = !!selectedObject; selectedObject = null; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; updateSelectionBubble(performance.now()); if (announce && hadSelection) toast('Selection cleared.'); } function updateSelectedLabel() { if (selectedAssetId && !findAsset(selectedAssetId)) { selectedAssetId = state.assets.find((asset) => !state.hiddenAssets?.[asset.id])?.id || state.assets[0]?.id || null; } const asset = findAsset(selectedAssetId); if (els.selectedAssetName) { els.selectedAssetName.textContent = asset ? `${cleanWorkText(asset.name || 'Untitled', 'Untitled')} / ${cap(subtypeToRole(asset))}` : 'No collection work selected'; } } function inspectAt(x, y) { const tile = world.get(x, y); const staticObjects = getPlacedAtTile(x, y).filter((p) => isWorldObjectVisibleByRotation('static', p.id)); const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => isWorldObjectVisibleByRotation('dynamic', p.id)); // Object selection is now based on actual opaque sprite pixels only. // A plain tile click updates information but does not select hidden/transparent areas. selectedObject = null; updateSelectionBubble(performance.now()); const staticNames = staticObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); const dynamicNames = dynamicObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); const lines = [`Tile ${x}, ${y}`, `Terrain: ${cap(tile.type)}`]; if (staticNames.length) lines.push(`Static here: ${staticNames.join(', ')}`); if (dynamicNames.length) lines.push(`Dynamic homes: ${dynamicNames.join(', ')}`); if (els.tileInfo) els.tileInfo.textContent = lines.join('\n'); } function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now(), options = {}) { selectedObject = { kind, id: objectId, assetId, selectedAt, bounce: options.bounce !== false }; cameraFollowSelected = options.follow !== false; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; selectedAssetId = assetId; updateSelectedLabel(); if (options.renderLibrary !== false) renderLibrary({ preserveScroll: true }); render(selectedAt); updateSelectionBubble(selectedAt); } function isTileCompatibleForAsset(asset, tile) { if (!tile) return false; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water'; if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water'; if (tile.type === 'water') return asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship'); return true; } function getRemixSourcePositions(asset) { const ids = new Set([asset?.parentAssetId, asset?.originalAssetId].filter(Boolean)); if (!ids.size) return []; const out = []; for (const placed of state.placed || []) if (ids.has(placed.assetId)) out.push({ x: placed.x, y: placed.y }); for (const summon of state.dynamicSummons || []) if (ids.has(summon.assetId)) out.push({ x: summon.homeX, y: summon.homeY }); return out; } function avoidRemixSourceOverlap(asset, x, y) { return { x, y, moved: false }; } function placeSelected(x, y) { const asset = placementPreview?.asset || findAsset(selectedAssetId); if (!asset) { toast('Select an asset first.'); setDrawerOpen(true); setTab('library'); return; } const adjusted = avoidRemixSourceOverlap(asset, x, y); x = adjusted.x; y = adjusted.y; const tile = world.get(x, y); if (!canPlace(asset, tile)) return; const savedAssetId = placementPreview?.savedAssetId || selectedAssetId || asset.id; placementPreview = { asset, savedAssetId, x, y, selectedAt: performance.now() }; clearWorldSelection(false); syncPlacementUi(); if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now(), preview: true }); render(); toast('Position selected. Right-click or PLACE HERE to publish.'); } function publishSelectedAt(x, y) { if (placementPreview?.asset) { const adjusted = avoidRemixSourceOverlap(placementPreview.asset, x, y); x = adjusted.x; y = adjusted.y; const tile = world.get(x, y); if (!canPlace(placementPreview.asset, tile)) return; if (placementPreview.savedAssetId) { selectedAssetId = placementPreview.savedAssetId; placementPreview = null; syncPlacementUi(); placeSelected(x, y); return; } placementPreview.x = x; placementPreview.y = y; placementPreview.selectedAt = performance.now(); if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now(), preview: true }); render(); toast(adjusted.moved ? 'Preview nudged away from the remix source. Right-click or DRAW to exit.' : 'Preview set. Right-click or DRAW to exit.'); return; } const asset = findAsset(selectedAssetId); if (!asset) { toast('Select an asset first.'); setDrawerOpen(true); setTab('library'); return; } const adjusted = avoidRemixSourceOverlap(asset, x, y); x = adjusted.x; y = adjusted.y; let tile = world.get(x, y); if (!canPlace(asset, tile)) return; if (adjusted.moved) toast('Placed slightly away from the remix source to avoid overlap.'); // Each placement creates a distinct island object, so the same work can be summoned multiple times. const existingObject = null; const kind = asset.category === 'dynamic' ? 'dynamic' : 'static'; const wasLocallyHidden = existingObject ? Boolean(state.hiddenObjects?.[existingObject.id]) : false; const wasRotationHidden = existingObject ? !isWorldObjectVisibleByRotation(kind, existingObject.id) : false; const needsPublishSlot = !existingObject || wasLocallyHidden || wasRotationHidden; if (needsPublishSlot && !canPublishObject(existingObject ? 'republish' : 'publish')) return; const isNewObject = !existingObject; if (isNewObject && getWorldObjectCount() >= PHASE5_GUARDRAILS.maxWorldObjects) { toast(`Local storage object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Lower the display cap or remove local objects before adding more.`); return; } if (isSharedWorld()) { ensureLocalAccount(existingObject ? 'republish' : 'publish'); const actor = currentAccountId(); if (!normalizeOwnerAccountId(asset.ownerAccountId)) asset.ownerAccountId = actor; if (normalizeOwnerAccountId(asset.ownerAccountId) !== actor) { toast('Shared worlds only let you publish placements from assets you own. Use Remix to make your own version.'); return; } if (existingObject && !canCurrentAccountModifyObject(kind, existingObject)) return; const nextObject = asset.category === 'static' ? { ...(existingObject || {}), id: existingObject?.id || uid(), assetId: asset.id, ownerAccountId: actor, x, y, placedAt: Date.now(), publishedAt: existingObject?.publishedAt || Date.now(), status: 'active', version: Number(existingObject?.version || 0) + 1 } : { ...(existingObject || {}), id: existingObject?.id || uid(), assetId: asset.id, ownerAccountId: actor, homeX: x, homeY: y, createdAt: Date.now(), publishedAt: existingObject?.publishedAt || Date.now(), status: 'active', version: Number(existingObject?.version || 0) + 1 }; const command = makeSharedCommand(existingObject ? 'object.move' : 'object.publish', { kind, object: nextObject }); const publishCommand = makeSharedCommand('publish.asset_object', { kind, asset: normalizeAsset({ ...asset, ownerAccountId: actor }), object: nextObject }); queueSharedCommand(existingObject ? command : publishCommand); if (!existingObject) recordPendingSharedPublish(kind, nextObject, publishCommand.id); syncSharedWorld({ silent: true, reason: 'publish' }); setMode('inspect'); syncPlacementUi(); return; } let object = null; if (asset.category === 'static') { const existing = null; if (existing) { existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1; if (needsPublishSlot) { delete state.hiddenObjects?.[existing.id]; recordObjectPublish('static', existing, wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish'); } object = existing; selectWorldObject('static', existing.id, asset.id, performance.now(), { bounce: false }); toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { const placed = { id: uid(), assetId: asset.id, ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()), x, y, placedAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.placed.push(placed); recordObjectPublish('static', placed, 'publish'); object = placed; selectWorldObject('static', placed.id, asset.id, performance.now(), { bounce: false }); toast(`${asset.name} placed.`); } } else { const existing = null; if (existing) { existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1; if (needsPublishSlot) { delete state.hiddenObjects?.[existing.id]; recordObjectPublish('dynamic', existing, wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish'); } object = existing; toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { const summon = { id: uid(), assetId: asset.id, ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()), homeX: x, homeY: y, createdAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.dynamicSummons.push(summon); recordObjectPublish('dynamic', summon, 'publish'); object = summon; toast(`${asset.name} summoned.`); } hydrateRuntime(); if (object) selectWorldObject('dynamic', object.id, asset.id, performance.now(), { bounce: false }); } rebuildWorldIndex(); recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(kind, object)); if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); saveState(); updateRotationStats(); setMode('inspect'); syncPlacementUi(); } function canPlace(asset, tile) { if (!tile) return false; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship') && tile.type !== 'water') { toast('Water/ship objects need water tiles.'); return false; } if (asset.category === 'dynamic' && asset.subtype === 'fish' && tile.type !== 'water') { toast('Fish need water tiles.'); return false; } if (asset.category !== 'dynamic' || asset.subtype !== 'fish') { if (tile.type === 'water' && !(asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship'))) { toast('Use land for this asset.'); return false; } } return true; } function eraseAt(x, y) { const staticObjects = getPlacedAtTile(x, y); const staticTarget = staticObjects.at(-1); if (staticTarget) { if (!canCurrentAccountModifyObject('static', staticTarget)) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId: staticTarget.id })); return; } const item = removeObjectLocally('static', staticTarget.id); if (item) { if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Object'} removed.`); return; } } const dynamicObjects = getDynamicHomesAtTile(x, y); const dynamicTarget = dynamicObjects.at(-1); if (dynamicTarget) { if (!canCurrentAccountModifyObject('dynamic', dynamicTarget)) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId: dynamicTarget.id })); return; } const item = removeObjectLocally('dynamic', dynamicTarget.id); if (item) { if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Dynamic object'} removed.`); return; } } toast('Nothing to erase here.'); } function eraseObject(kind, objectId) { if (kind === 'static') { const index = state.placed.findIndex((p) => p.id === objectId); if (index >= 0) { if (!canCurrentAccountModifyObject('static', state.placed[index])) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId })); return; } const item = removeObjectLocally('static', objectId); if (!item) return; if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Object'} removed.`); return; } } if (kind === 'dynamic') { const index = state.dynamicSummons.findIndex((p) => p.id === objectId); if (index >= 0) { if (!canCurrentAccountModifyObject('dynamic', state.dynamicSummons[index])) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId })); return; } const item = removeObjectLocally('dynamic', objectId); if (!item) return; if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Dynamic object'} removed.`); } } } function usesRightButtonAsPaint() { return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; } function onPaintPointerDown(event) { event.preventDefault(); if (paintTool === 'select' && event.button === 2) { clearEditorSelection(true); return; } els.paintCanvas.setPointerCapture?.(event.pointerId); lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY, button: event.button || 0 }; isPainting = !isPan; mousePaint.active = false; mousePaint.panning = false; if (isPainting) { suppressNextPaintClick = true; suppressMousePaintUntil = performance.now() + 350; if (handleEditorSpecialPointerDown(event)) return; beginEditorGesture(); paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); if (paintTool === 'fill' || paintTool === 'pick') { isPainting = false; finishEditorGesture(); } } } function onPaintPointerMove(event) { if (editorPointer.pointerId !== event.pointerId) return; if (editorPointer.panning) { event.preventDefault(); const rect = els.paintCanvas.getBoundingClientRect(); const sx = els.paintCanvas.width / rect.width; const sy = els.paintCanvas.height / rect.height; editorView.x += (event.clientX - editorPointer.lastX) * sx; editorView.y += (event.clientY - editorPointer.lastY) * sy; editorPointer.lastX = event.clientX; editorPointer.lastY = event.clientY; clampEditorView(); drawEditor(); return; } if (shapeGesture || selectionGesture) { event.preventDefault(); handleEditorSpecialPointerMove(event); return; } if (!isPainting) return; if (event.buttons !== undefined) { const requiredButton = editorPointer?.button === 2 ? 2 : 1; if ((event.buttons & requiredButton) === 0) return; } event.preventDefault(); paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); } function onPaintPointerUp(event) { if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false; if (shapeGesture || selectionGesture) handleEditorSpecialPointerUp(event); isPainting = false; lastPaintedKey = ''; finishEditorGesture(); } function onPaintClick(event) { if (event.button !== 0) return; if (suppressNextPaintClick) { suppressNextPaintClick = false; return; } event.preventDefault(); lastPaintedKey = ''; if (paintTool === 'line' || paintTool === 'rect' || paintTool === 'select') return; beginEditorGesture(); paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); finishEditorGesture(); } function onPaintWheel(event) { event.preventDefault(); const rect = els.paintCanvas.getBoundingClientRect(); const scaleX = els.paintCanvas.width / rect.width; const scaleY = els.paintCanvas.height / rect.height; const sx = (event.clientX - rect.left) * scaleX; const sy = (event.clientY - rect.top) * scaleY; const worldX = (sx - editorView.x) / editorView.zoom; const worldY = (sy - editorView.y) / editorView.zoom; const nextZoom = clamp(editorView.zoom * (event.deltaY > 0 ? 1 / 1.12 : 1.12), 1, 24); editorView.zoom = nextZoom; editorView.x = sx - worldX * editorView.zoom; editorView.y = sy - worldY * editorView.zoom; clampEditorView(); drawEditor(); } function onPaintMouseDown(event) { // Fallback for browsers/extensions where pointer events are swallowed. if (event.button !== 0 && event.button !== 1 && event.button !== 2) return; event.preventDefault(); if (paintTool === 'select' && event.button === 2) { clearEditorSelection(true); return; } lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button }; if (performance.now() < suppressMousePaintUntil) return; if (mousePaint.active) { beginEditorGesture(); paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey); if (paintTool === 'fill' || paintTool === 'pick') { mousePaint.active = false; finishEditorGesture(); } } } function onPaintMouseMove(event) { if (!mousePaint.active && !mousePaint.panning) return; event.preventDefault(); if (mousePaint.panning) { const rect = els.paintCanvas.getBoundingClientRect(); const sx = els.paintCanvas.width / rect.width; const sy = els.paintCanvas.height / rect.height; editorView.x += (event.clientX - mousePaint.lastX) * sx; editorView.y += (event.clientY - mousePaint.lastY) * sy; mousePaint.lastX = event.clientX; mousePaint.lastY = event.clientY; clampEditorView(); drawEditor(); return; } paintAtClient(event.clientX, event.clientY, mousePaint.button || 0, event.shiftKey); } function onPaintMouseUp() { mousePaint.active = false; mousePaint.panning = false; lastPaintedKey = ''; finishEditorGesture(); } function paintAtEvent(event) { paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); } function paintAtClient(clientX, clientY, button = 0, shiftKey = false) { const local = clientToPaintLocal(clientX, clientY); const x = Math.floor(local.x); const y = Math.floor(local.y); if (x < 0 || y < 0 || x >= editorWidth || y >= editorHeight) return; const key = `${x},${y},${paintTool},${button},${shiftKey}`; if (key === lastPaintedKey && paintTool !== 'fill') return; lastPaintedKey = key; const point = displayCellToCanonical(x, y); const index = point.y * editorSize + point.x; let changed = false; if (paintTool === 'pick') { const picked = editorPixels[index]; if (picked) selectPaletteCode(picked); setPaintTool('brush'); drawEditor(); return; } if (paintTool === 'fill') { const displayPixels = compactPixelsFromStride(editorPixels, editorSize, editorWidth, editorHeight); const fillColor = shiftKey ? null : selectedColorCode; const actions = editorActions(); const nextDisplay = actions?.floodFill ? actions.floodFill(displayPixels, editorWidth, x, y, fillColor, editorHeight) : fallbackFloodFill(displayPixels, editorWidth, x, y, fillColor, editorHeight); if (nextDisplay.changed) { setDisplayEditorPixels(nextDisplay.pixels); if (fillColor === null && Array.isArray(nextDisplay.cells)) { for (const cell of nextDisplay.cells) { const src = displayCellToCanonical(cell.x, cell.y); removeLightPixel(src.x, src.y); removeParticlePixel(src.x, src.y); } } changed = true; } } else if (paintTool === 'brush') { if (editorPixels[index] !== selectedColorCode) { editorPixels[index] = selectedColorCode; changed = true; } } else if (paintTool === 'erase') { const hadLight = lightPixels.some((p) => p.x === point.x && p.y === point.y); const hadParticle = particlePixels.some((p) => p.x === point.x && p.y === point.y); const hadDepth = depthPixels[index] !== 0; if (hadLight || hadParticle || hadDepth) { removeLightPixel(point.x, point.y); removeParticlePixel(point.x, point.y); depthPixels[index] = 0; changed = true; } else if (editorPixels[index] !== null) { editorPixels[index] = null; changed = true; } } else if (paintTool === 'light') { const before = JSON.stringify(lightPixels); if (shiftKey || button === 2) removeLightPixel(point.x, point.y); else addLightPixel(point.x, point.y, selectedColorCode); changed = JSON.stringify(lightPixels) !== before; updateSettingsSummary(); } else if (paintTool === 'particle') { const before = JSON.stringify(particlePixels); if (shiftKey || button === 2) removeParticlePixel(point.x, point.y); else addParticlePixel(point.x, point.y, selectedColorCode, els.particleDirection?.value || particleConfig.dir || 'up'); particleConfig = normalizeParticleConfig({ enabled: particlePixels.length > 0, c: selectedColorCode, dir: els.particleDirection?.value || particleConfig.dir || 'up' }); changed = JSON.stringify(particlePixels) !== before; updateParticleUI(); updateSettingsSummary(); } else if (paintTool === 'depth') { const next = (shiftKey || button === 2) ? 0 : depthPaintMode; if (depthPixels[index] !== next) { depthPixels[index] = next; changed = true; } } else if (paintTool === 'door') { if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') { if (doorPixel.x !== x || doorPixel.y !== y) { doorPixel = { x, y }; changed = true; } updateSettingsSummary(); } } if (changed) { editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); markEditorChanged(); drawEditor(); } } function paintEventToCell(event) { const local = paintEventToLocal(event); return { x: Math.floor(local.x), y: Math.floor(local.y) }; } function paintEventToLocal(event) { return clientToPaintLocal(event.clientX, event.clientY); } function clientToPaintLocal(clientX, clientY) { const rect = els.paintCanvas.getBoundingClientRect(); const scaleX = els.paintCanvas.width / rect.width; const scaleY = els.paintCanvas.height / rect.height; const sx = (clientX - rect.left) * scaleX; const sy = (clientY - rect.top) * scaleY; const cell = editorCellSize(); return { x: (sx - editorView.x) / editorView.zoom / cell, y: (sy - editorView.y) / editorView.zoom / cell }; } function snapshotEditorState() { return { size: editorSize, width: editorWidth, height: editorHeight, rightPixels: [...editorPixels], depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), particlePixels: particlePixels.map((p) => ({ ...p })), particleConfig: { ...particleConfig }, doorPixel: { ...doorPixel }, editingSide, selectedColorCode, editorSelection: editorSelection ? { ...editorSelection } : null }; } function restoreEditorState(snapshot) { editorWidth = clampDimension(snapshot.width ?? snapshot.size, editorWidth); editorHeight = clampDimension(snapshot.height ?? snapshot.size, editorHeight); editorSize = Math.max(editorWidth, editorHeight); editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; particlePixels = Array.isArray(snapshot.particlePixels) ? snapshot.particlePixels.map((p) => ({ ...p })) : []; particleConfig = normalizeParticleConfig(snapshot.particleConfig || (particlePixels.length ? { enabled: true, c: particlePixels[0].c, dir: particlePixels[0].dir || 'up' } : particleConfig)); doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorWidth / 2), y: editorHeight - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null; updateDimensionInputs(); updateSideButtons(); renderPalette(); drawEditor(); updateSettingsSummary(); updateEditorHistoryButtons(); updateEditorSelectionButtons(); } function beginEditorGesture() { if (!editorGestureSnapshot) { editorGestureSnapshot = snapshotEditorState(); editorGestureChanged = false; } } function markEditorChanged() { editorGestureChanged = true; } function finishEditorGesture() { if (!editorGestureSnapshot) return; if (editorGestureChanged) { editorHistory.push(editorGestureSnapshot); if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); editorFuture = []; } editorGestureSnapshot = null; editorGestureChanged = false; updateEditorHistoryButtons(); } function commitEditorMutation(mutator) { const before = snapshotEditorState(); const changed = Boolean(mutator()); if (changed) { editorHistory.push(before); if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); editorFuture = []; editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); drawEditor(); updateSettingsSummary(); updateEditorHistoryButtons(); } return changed; } function clearEditorHistory() { editorHistory = []; editorFuture = []; editorGestureSnapshot = null; editorGestureChanged = false; updateEditorHistoryButtons(); } function undoEditor() { if (!editorHistory.length) return; editorFuture.push(snapshotEditorState()); restoreEditorState(editorHistory.pop()); } function redoEditor() { if (!editorFuture.length) return; editorHistory.push(snapshotEditorState()); restoreEditorState(editorFuture.pop()); } function updateEditorHistoryButtons() { if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0; if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0; updateEditorSelectionButtons(); } function onEditorKeyDown(event) { if (!els.drawer?.classList.contains('open')) return; const key = event.key.toLowerCase(); const activeTag = document.activeElement?.tagName?.toLowerCase(); if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return; if ((event.ctrlKey || event.metaKey) && key === 'z') { event.preventDefault(); if (event.shiftKey) redoEditor(); else undoEditor(); } else if ((event.ctrlKey || event.metaKey) && key === 'y') { event.preventDefault(); redoEditor(); } else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) { event.preventDefault(); const step = event.shiftKey ? 4 : 1; const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key]; moveSelectionBy(delta[0], delta[1]); } else if (key === 'escape') { clearEditorSelection(false); } else if (!event.ctrlKey && !event.metaKey && !event.altKey) { const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' }; if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); } } } function fallbackFloodFill(sourcePixels, width, x, y, colorCode, height = width) { const w = Math.max(1, Math.round(Number(width) || 1)); const h = Math.max(1, Math.round(Number(height) || w)); const pixels = [...sourcePixels]; const target = pixels[y * w + x] || null; const replacement = colorCode || null; if (target === replacement) return { pixels, changed: false, count: 0, cells: [] }; const stack = [[x, y]]; const cells = []; while (stack.length) { const [cx, cy] = stack.pop(); if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; const index = cy * w + cx; if ((pixels[index] || null) !== target) continue; pixels[index] = replacement; cells.push({ x: cx, y: cy }); stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); } return { pixels, changed: cells.length > 0, count: cells.length, cells }; } function handleEditorSpecialPointerDown(event) { const cell = clientToCell(event.clientX, event.clientY); if (!cell) return false; if (paintTool === 'line' || paintTool === 'rect') { beginEditorGesture(); shapeGesture = { tool: paintTool, startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y, erase: event.button === 2, filled: event.shiftKey }; shapePreview = { ...shapeGesture }; drawEditor(); return true; } if (paintTool === 'select') { const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection); if (inside) { beginEditorGesture(); const baseSelection = { ...editorSelection }; selectionGesture = { mode: 'move', startX: cell.x, startY: cell.y, lastDx: 0, lastDy: 0, baseSelection, basePixels: getDisplayEditorPixels(), baseDepthPixels: getDisplayDepthPixels(), baseLightPixels: lightPixels.map((p) => ({ ...p })), baseParticlePixels: particlePixels.map((p) => ({ ...p })) }; } else { selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y }; editorSelection = normalizeSelectionRect(cell.x, cell.y, cell.x, cell.y); updateEditorSelectionButtons(); drawEditor(); } return true; } return false; } function handleEditorSpecialPointerMove(event) { const cell = clientToCell(event.clientX, event.clientY); if (!cell) return; if (shapeGesture) { shapeGesture.endX = cell.x; shapeGesture.endY = cell.y; shapeGesture.filled = event.shiftKey; shapePreview = { ...shapeGesture }; drawEditor(); return; } if (selectionGesture?.mode === 'select') { selectionGesture.endX = cell.x; selectionGesture.endY = cell.y; editorSelection = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, cell.x, cell.y); updateEditorSelectionButtons(); drawEditor(); return; } if (selectionGesture?.mode === 'move') { let dx = cell.x - selectionGesture.startX; let dy = cell.y - selectionGesture.startY; ({ dx, dy } = clampSelectionDelta(selectionGesture.baseSelection, dx, dy)); if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return; selectionGesture.lastDx = dx; selectionGesture.lastDy = dy; applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseParticlePixels, selectionGesture.baseSelection, dx, dy); markEditorChanged(); drawEditor(); } } function handleEditorSpecialPointerUp(event) { if (shapeGesture) { const changed = commitShapeGesture(shapeGesture); if (changed) markEditorChanged(); shapeGesture = null; shapePreview = null; drawEditor(); return; } if (selectionGesture?.mode === 'select') { const rect = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, selectionGesture.endX, selectionGesture.endY); editorSelection = rect && rect.w * rect.h > 0 ? rect : null; selectionGesture = null; drawEditor(); updateEditorSelectionButtons(); return; } if (selectionGesture?.mode === 'move') { selectionGesture = null; editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); updateEditorSelectionButtons(); return; } } function clientToCell(clientX, clientY) { const local = clientToPaintLocal(clientX, clientY); const x = Math.floor(local.x); const y = Math.floor(local.y); if (x < 0 || y < 0 || x >= editorWidth || y >= editorHeight) return null; return { x, y }; } function normalizeSelectionRect(x0, y0, x1, y1) { const left = clamp(Math.min(x0, x1), 0, editorWidth - 1); const top = clamp(Math.min(y0, y1), 0, editorHeight - 1); const right = clamp(Math.max(x0, x1), 0, editorWidth - 1); const bottom = clamp(Math.max(y0, y1), 0, editorHeight - 1); return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 }; } function pointInSelection(x, y, selection) { return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h; } function clampSelectionDelta(selection, dx, dy) { if (!selection) return { dx: 0, dy: 0 }; return { dx: clamp(dx, -selection.x, editorWidth - (selection.x + selection.w)), dy: clamp(dy, -selection.y, editorHeight - (selection.y + selection.h)) }; } function getDisplayDepthPixels() { return normalizeDepthPixels(depthPixels, editorSize); } function setDisplayEditorPixels(pixels) { editorPixels = Array.isArray(pixels) && pixels.length === editorWidth * editorHeight && (editorWidth !== editorSize || editorHeight !== editorSize) ? inflatePixelsToStride(pixels, editorWidth, editorHeight, editorSize) : normalizePixels(pixels, editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); } function setDisplayDepthPixels(pixels) { depthPixels = Array.isArray(pixels) && pixels.length === editorWidth * editorHeight && (editorWidth !== editorSize || editorHeight !== editorSize) ? inflateDepthToStride(pixels, editorWidth, editorHeight, editorSize) : normalizeDepthPixels(pixels, editorSize); } function mirrorScalarPixels(values, stride, width = stride, height = width) { const source = normalizeDepthPixels(values, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { out[y * stride + (width - 1 - x)] = source[y * stride + x] || 0; } } return out; } function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, baseParticlePixels, selection, dx, dy) { const nextPixels = normalizePixels(basePixels, editorSize); const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize); const movedPixels = [...nextPixels]; const movedDepth = [...nextDepth]; for (let y = selection.y; y < selection.y + selection.h; y++) { for (let x = selection.x; x < selection.x + selection.w; x++) { const index = y * editorSize + x; movedPixels[index] = null; movedDepth[index] = 0; } } for (let y = selection.y; y < selection.y + selection.h; y++) { for (let x = selection.x; x < selection.x + selection.w; x++) { const from = y * editorSize + x; const to = (y + dy) * editorSize + (x + dx); movedPixels[to] = nextPixels[from] || null; movedDepth[to] = nextDepth[from] || 0; } } setDisplayEditorPixels(movedPixels); setDisplayDepthPixels(movedDepth); lightPixels = moveDisplayPoints(baseLightPixels, selection, dx, dy); particlePixels = moveDisplayPoints(baseParticlePixels, selection, dx, dy); editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h }; updateEditorSelectionButtons(); } function moveDisplayPoints(sourcePoints, selection, dx, dy) { const out = []; for (const point of sourcePoints || []) { const shown = canonicalCellToDisplay(point.x, point.y); if (!pointInSelection(shown.x, shown.y, selection)) { out.push({ ...point }); continue; } const nx = shown.x + dx; const ny = shown.y + dy; if (nx < 0 || ny < 0 || nx >= editorWidth || ny >= editorHeight) continue; const canonical = displayCellToCanonical(nx, ny); out.push({ ...point, x: canonical.x, y: canonical.y }); } return out; } function moveSelectionBy(dx, dy) { if (!editorSelection) return false; const bounded = clampSelectionDelta(editorSelection, dx, dy); if (!bounded.dx && !bounded.dy) return false; return commitEditorMutation(() => { applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); return true; }); } function nudgeEditor(dx, dy) { if (editorSelection) moveSelectionBy(dx, dy); else shiftEditorContent(dx, dy); } function shiftEditorContent(dx, dy) { return commitEditorMutation(() => { const selection = { x: 0, y: 0, w: editorWidth, h: editorHeight }; const bounded = clampSelectionDelta(selection, dx, dy); if (!bounded.dx && !bounded.dy) return false; applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); editorSelection = null; doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorWidth - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorHeight - 1) }; return true; }); } function commitShapeGesture(gesture) { const cells = gesture.tool === 'line' ? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY) : getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled); if (!cells.length) return false; const pixels = getDisplayEditorPixels(); const value = gesture.erase ? null : selectedColorCode; let changed = false; for (const cell of cells) { const index = cell.y * editorSize + cell.x; if ((pixels[index] || null) !== value) { pixels[index] = value; changed = true; } if (gesture.erase) { const canonical = displayCellToCanonical(cell.x, cell.y); removeLightPixel(canonical.x, canonical.y); } } if (!changed) return false; setDisplayEditorPixels(pixels); return true; } function getLineCells(x0, y0, x1, y1) { const cells = []; let dx = Math.abs(x1 - x0); let dy = -Math.abs(y1 - y0); const sx = x0 < x1 ? 1 : -1; const sy = y0 < y1 ? 1 : -1; let err = dx + dy; let x = x0; let y = y0; while (true) { cells.push({ x, y }); if (x === x1 && y === y1) break; const e2 = 2 * err; if (e2 >= dy) { err += dy; x += sx; } if (e2 <= dx) { err += dx; y += sy; } } return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorWidth && cell.y < editorHeight); } function getRectCells(x0, y0, x1, y1, filled = false) { const rect = normalizeSelectionRect(x0, y0, x1, y1); const cells = []; for (let y = rect.y; y < rect.y + rect.h; y++) { for (let x = rect.x; x < rect.x + rect.w; x++) { if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y }); } } return cells; } function drawEditorOverlays(cell) { if (shapePreview) { const cells = shapePreview.tool === 'line' ? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY) : getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled); pctx.save(); pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42; pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode); for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell)); pctx.restore(); } if (editorSelection) { pctx.save(); pctx.strokeStyle = '#ff4fa3'; pctx.lineWidth = Math.max(2, 2 / editorView.zoom); pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]); pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom); pctx.fillStyle = 'rgba(255, 79, 163, .08)'; pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell); pctx.restore(); } } function updateEditorSelectionButtons() { const disabled = !editorSelection; if (els.clearSelection) els.clearSelection.disabled = disabled; } function clearEditorSelection(announce = false) { const hadSelection = !!editorSelection || !!selectionGesture; editorSelection = null; selectionGesture = null; drawEditor(); updateEditorSelectionButtons(); if (announce && hadSelection) toast('Selection cleared.'); } function normalizeParticleConfig(config, size = editorSize) { const input = config && typeof config === 'object' ? config : {}; const dir = ['up', 'down', 'left', 'right'].includes(input.dir) ? input.dir : 'up'; const color = PALETTE_BY_CODE[input.c] ? input.c : nearestPaletteCode('#ffffff'); return { enabled: Boolean(input.enabled), c: color, dir }; } function particleDirectionLabel(dir) { return ({ up: '↑', down: '↓', left: '←', right: '→' }[dir] || 'Unknown'); } function particleRangeLabel() { return `${particlePixels.length} emitter cell${particlePixels.length === 1 ? '' : 's'}`; } function updateParticleUI() { particleConfig = normalizeParticleConfig(particleConfig); if (els.particleDirection) els.particleDirection.value = particleConfig.dir; toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); if (els.toolParticle) els.toolParticle.classList.toggle('active', paintTool === 'particle' || particlePixels.length > 0); if (els.particleRangeStatus) els.particleRangeStatus.textContent = `Particle cells: ${particlePixels.length}. Shift/right-click clears a cell.`; if (els.particleClearRange) els.particleClearRange.disabled = particlePixels.length === 0; } function enableParticleEffect() { particleConfig = normalizeParticleConfig({ enabled: true, c: selectedColorCode, dir: els.particleDirection?.value || particleConfig.dir || 'up' }); updateParticleUI(); updateSettingsSummary(); drawEditor(); } function disableParticleEffect() { if (!particlePixels.length && !particleConfig.enabled) return; particleConfig = normalizeParticleConfig({ ...particleConfig, enabled: false }); updateParticleUI(); updateSettingsSummary(); drawEditor(); toast('Particle tool ready. Shift/right-click a particle cell to clear it.'); } function displayRectToCanonicalRect(rect) { return null; } function setParticleRangeFromSelection() { toast('Particle range is no longer used. Paint particle cells directly.'); } function clearParticleRange() { if (!particlePixels.length) return; commitEditorMutation(() => { particlePixels = []; particleConfig = normalizeParticleConfig({ ...particleConfig, enabled: false }); return true; }); toast('Particle cells cleared.'); } function getPixelBounds(pixels, size) { const source = normalizePixels(pixels, size); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (!source[y * size + x]) continue; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } return Number.isFinite(minX) ? { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 } : null; } function flipEditorHorizontal() { commitEditorMutation(() => { editorPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = mirrorScalarPixels(depthPixels, editorSize, editorWidth, editorHeight); lightPixels = lightPixels.map((p) => ({ ...p, x: editorWidth - 1 - p.x })); particlePixels = particlePixels.map((p) => ({ ...p, x: editorWidth - 1 - p.x })); doorPixel = { ...doorPixel, x: editorWidth - 1 - doorPixel.x }; editorSelection = editorSelection ? { x: editorWidth - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; return true; }); } function flipEditorVertical() { commitEditorMutation(() => { editorPixels = flipPixelsVertical(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = flipScalarPixelsVertical(depthPixels, editorSize, editorWidth, editorHeight); lightPixels = lightPixels.map((p) => ({ ...p, y: editorHeight - 1 - p.y })); particlePixels = particlePixels.map((p) => ({ ...p, y: editorHeight - 1 - p.y })); doorPixel = { ...doorPixel, y: editorHeight - 1 - doorPixel.y }; editorSelection = editorSelection ? { x: editorSelection.x, y: editorHeight - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; return true; }); } function flipPixelsVertical(pixels, stride, width = stride, height = width) { const source = normalizePixels(pixels, stride); const out = blankPixels(stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[(height - 1 - y) * stride + x] = source[y * stride + x] || null; } return out; } function flipScalarPixelsVertical(values, stride, width = stride, height = width) { const source = normalizeDepthPixels(values, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[(height - 1 - y) * stride + x] = source[y * stride + x] || 0; } return out; } function applyEditorOutline() { commitEditorMutation(() => { const source = [...editorPixels]; const out = [...editorPixels]; let changed = false; for (let y = 0; y < editorHeight; y++) { for (let x = 0; x < editorWidth; x++) { const index = y * editorSize + x; if (source[index]) continue; const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => { const nx = x + dx; const ny = y + dy; return nx >= 0 && ny >= 0 && nx < editorWidth && ny < editorHeight && source[ny * editorSize + nx]; }); if (adjacent) { out[index] = selectedColorCode; changed = true; } } } if (!changed) return false; editorPixels = out; return true; }); } function rgbToHex(r, g, b) { const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'); return `#${part(r)}${part(g)}${part(b)}`; } function selectPaletteCode(code) { if (!PALETTE_BY_CODE[code]) return; selectedColorCode = code; els.paintColor.value = PALETTE_BY_CODE[code]; if (paintTool === 'particle') particleConfig = normalizeParticleConfig({ ...particleConfig, c: code }); renderPalette(); updateParticleUI(); updateSettingsSummary(); } function getActiveEditorPixels() { return editorPixels; } function getDisplayEditorPixels() { return normalizePixels(editorPixels, editorSize); } function displayCellToCanonical(x, y) { return { x, y }; } function canonicalCellToDisplay(x, y) { return { x, y }; } function compactPixelsFromStride(pixels, stride, width, height) { const source = normalizePixels(pixels, stride); const out = blankPixels(width, height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * width + x] = source[y * stride + x] || null; } return out; } function compactDepthFromStride(values, stride, width, height) { const source = normalizeDepthPixels(values, stride); const out = Array(width * height).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * width + x] = source[y * stride + x] || 0; } return out; } function inflatePixelsToStride(pixels, width, height, stride) { const source = normalizePixels(pixels, width, height); const out = blankPixels(stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + x] = source[y * width + x] || null; } return out; } function inflateDepthToStride(values, width, height, stride) { const source = normalizeDepthPixels(values, width, height); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + x] = source[y * width + x] || 0; } return out; } function resizeEditorPlane(source, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, fill = null) { const out = Array(newStride * newStride).fill(fill); const minW = Math.min(oldWidth, newWidth); const minH = Math.min(oldHeight, newHeight); const src = Array.isArray(source) ? source : []; for (let y = 0; y < minH; y++) { for (let x = 0; x < minW; x++) out[y * newStride + x] = src[y * oldStride + x] ?? fill; } return out; } function resizeEditorCanvas(nextWidth, nextHeight) { const newWidth = clampDimension(nextWidth, editorWidth); const newHeight = clampDimension(nextHeight, editorHeight); const newStride = Math.max(newWidth, newHeight); if (newWidth === editorWidth && newHeight === editorHeight && newStride === editorSize) return false; commitEditorMutation(() => { const oldStride = editorSize; const oldWidth = editorWidth; const oldHeight = editorHeight; const nextPixels = resizeEditorPlane(editorPixels, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, null); const nextDepth = resizeEditorPlane(depthPixels, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, 0); editorWidth = newWidth; editorHeight = newHeight; editorSize = newStride; editorPixels = normalizePixels(nextPixels, editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = normalizeDepthPixels(nextDepth, editorSize); lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, false); particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < editorWidth && p.y < editorHeight && editorPixels[p.y * editorSize + p.x]); doorPixel = { x: clamp(doorPixel.x, 0, editorWidth - 1), y: clamp(doorPixel.y, 0, editorHeight - 1) }; editorSelection = null; updateDimensionInputs(); resetEditorView(); return true; }); return true; } function setupEditor(sizeOrWidth, rightPixels, leftPixels = null, nextDepthPixels = null, nextHeight = null) { const width = clampDimension(sizeOrWidth, 8); const height = clampDimension(nextHeight ?? sizeOrWidth, width); const stride = Math.max(width, height); editorWidth = width; editorHeight = height; editorSize = stride; const normalizedRight = inflatePixelsToStride(rightPixels, width, height, stride); const normalizedLeft = leftPixels ? inflatePixelsToStride(leftPixels, width, height, stride) : null; const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorEditorPixelsHorizontal(normalizedLeft, stride, width, height); editorPixels = normalizePixels(canonical, stride); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, stride, width, height); editorSelection = null; depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? inflateDepthToStride(nextDepthPixels, width, height, stride) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || stride, stride); updateDimensionInputs(); lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, false); particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < editorWidth && p.y < editorHeight && editorPixels[p.y * editorSize + p.x]); particleConfig = normalizeParticleConfig(particleConfig); doorPixel = { x: clamp(doorPixel.x, 0, editorWidth - 1), y: clamp(doorPixel.y, 0, editorHeight - 1) }; resetEditorView(); drawEditor(); updateEditorHistoryButtons(); } function drawEditor() { clampEditorView(); const canvas = els.paintCanvas; const rectSize = canvas.width; const cell = editorCellSize(); const activeW = editorWidth * cell; const activeH = editorHeight * cell; pctx.clearRect(0, 0, rectSize, rectSize); pctx.fillStyle = '#fffaf0'; pctx.fillRect(0, 0, rectSize, rectSize); pctx.fillStyle = 'rgba(36, 48, 68, .05)'; if (activeW < rectSize) pctx.fillRect(activeW, 0, rectSize - activeW, rectSize); if (activeH < rectSize) pctx.fillRect(0, activeH, rectSize, rectSize - activeH); pctx.save(); pctx.translate(editorView.x, editorView.y); pctx.scale(editorView.zoom, editorView.zoom); const pixels = getDisplayEditorPixels(); for (let y = 0; y < editorHeight; y++) { for (let x = 0; x < editorWidth; x++) { const color = pixels[y * editorSize + x]; if (!color) continue; pctx.fillStyle = colorToHex(color); pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell)); } } if (advancedDraw && depthPixels?.length) { if (paintTool === 'depth') { pctx.fillStyle = 'rgba(46, 89, 160, .07)'; pctx.fillRect(0, 0, rectSize, rectSize); } for (let y = 0; y < editorHeight; y++) { for (let x = 0; x < editorWidth; x++) { const source = displayCellToCanonical(x, y); const depth = depthPixels[source.y * editorSize + source.x] || 0; if (!depth) continue; const high = depth > 0; pctx.fillStyle = high ? 'rgba(34, 116, 255, .62)' : 'rgba(88, 44, 120, .62)'; pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); pctx.strokeStyle = high ? 'rgba(6, 28, 75, .55)' : 'rgba(35, 13, 54, .55)'; pctx.lineWidth = Math.max(1, 2 / editorView.zoom); pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); } } } pctx.strokeStyle = 'rgba(36, 48, 68, .13)'; pctx.lineWidth = 1 / editorView.zoom; for (let i = 0; i <= editorWidth; i++) { const p = Math.round(i * cell) + .5; pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, activeH); pctx.stroke(); } for (let i = 0; i <= editorHeight; i++) { const p = Math.round(i * cell) + .5; pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(activeW, p); pctx.stroke(); } { for (const light of lightPixels) { const shown = canonicalCellToDisplay(light.x, light.y); const lx = (shown.x + .5) * cell; const ly = (shown.y + .5) * cell; pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28); pctx.beginPath(); pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2); pctx.fill(); pctx.strokeStyle = colorToHex(light.c || selectedColorCode); pctx.lineWidth = 2 / editorView.zoom; pctx.beginPath(); pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2); pctx.stroke(); } for (const emitter of particlePixels) { const shown = canonicalCellToDisplay(emitter.x, emitter.y); const lx = (shown.x + .5) * cell; const ly = (shown.y + .5) * cell; const color = colorToHex(emitter.c || selectedColorCode); pctx.fillStyle = hexToRgba(color, .20); pctx.fillRect(lx - Math.max(3, cell * .18), ly - Math.max(3, cell * .18), Math.max(6, cell * .36), Math.max(6, cell * .36)); pctx.strokeStyle = color; pctx.lineWidth = 2 / editorView.zoom; pctx.strokeRect(lx - Math.max(2, cell * .12), ly - Math.max(2, cell * .12), Math.max(4, cell * .24), Math.max(4, cell * .24)); } if (currentRole() === 'building') { pctx.strokeStyle = '#5fb8ff'; pctx.lineWidth = 3 / editorView.zoom; pctx.strokeRect(doorPixel.x * cell + 2 / editorView.zoom, doorPixel.y * cell + 2 / editorView.zoom, cell - 4 / editorView.zoom, cell - 4 / editorView.zoom); } } drawEditorOverlays(cell); pctx.restore(); } function resetEditorView() { editorView.zoom = 1; editorView.x = 0; editorView.y = 0; clampEditorView(); } function clampEditorView() { const size = els.paintCanvas.width; const cell = editorCellSize(); const scaledW = editorWidth * cell * editorView.zoom; const scaledH = editorHeight * cell * editorView.zoom; const minX = Math.min(0, size - scaledW); const minY = Math.min(0, size - scaledH); editorView.x = clamp(editorView.x, minX, 0); editorView.y = clamp(editorView.y, minY, 0); } function sanitizeLightPixels(points, pixels, stride, width = stride, height = width, fallbackColor = selectedColorCode, announce = false) { const source = normalizePixels(pixels || [], stride); const maxLights = lightBudgetForArea(width, height); const seen = new Set(); const clean = []; for (const raw of Array.isArray(points) ? points : []) { const x = clampInt(raw.x, 0, width - 1, 0); const y = clampInt(raw.y, 0, height - 1, 0); const key = `${x},${y}`; if (seen.has(key)) continue; if (!source[y * stride + x]) continue; seen.add(key); clean.push({ x, y, c: raw.c || fallbackColor }); if (clean.length >= maxLights) break; } if (announce && clean.length < (Array.isArray(points) ? points.length : 0)) toast(`Light cells limited to ${maxLights} and must sit on non-transparent pixels.`); return clean; } function addLightPixel(x, y, colorCode = selectedColorCode) { const point = { x: clamp(Math.floor(Number(x)), 0, editorWidth - 1), y: clamp(Math.floor(Number(y)), 0, editorHeight - 1), c: colorCode }; if (!editorPixels[point.y * editorSize + point.x]) { toast('Light cells must be placed on painted pixels.'); return; } const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y); if (existing) existing.c = colorCode; else { const maxLights = lightBudgetForArea(editorWidth, editorHeight); if (lightPixels.length >= maxLights) { toast(`Light limit: ${maxLights} for ${editorWidth}x${editorHeight} cells.`); return; } lightPixels.push(point); } lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, true); } function removeLightPixel(x, y) { lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); } function addParticlePixel(x, y, colorCode = selectedColorCode, dir = particleConfig.dir || 'up') { const point = { x: clamp(Math.floor(Number(x)), 0, editorWidth - 1), y: clamp(Math.floor(Number(y)), 0, editorHeight - 1), c: colorCode, dir: ['up','down','left','right'].includes(dir) ? dir : 'up' }; const existing = particlePixels.find((p) => p.x === point.x && p.y === point.y); if (existing) { existing.c = colorCode; existing.dir = point.dir; } else particlePixels.push(point); } function removeParticlePixel(x, y) { particlePixels = particlePixels.filter((p) => !(p.x === x && p.y === y)); } function mirrorLightPointsHorizontal(points, width, height = width) { return (Array.isArray(points) ? points : []) .map((p) => ({ ...p, x: width - 1 - clampInt(p.x, 0, width - 1, 0) })) .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } function mirrorParticlePointsHorizontal(points, width, height = width) { return (Array.isArray(points) ? points : []) .map((p) => ({ ...p, x: width - 1 - clampInt(p.x, 0, width - 1, 0), dir: p.dir === 'left' ? 'right' : p.dir === 'right' ? 'left' : (p.dir || 'up') })) .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } function shiftParticlePointsVertical(points, width, height = width, shift = 0) { return (Array.isArray(points) ? points : []) .map((p) => ({ ...p, y: clampInt(p.y, 0, height - 1, 0) + shift })) .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } function mirrorEditorPixelsHorizontal(pixels, stride, width, height) { const source = normalizePixels(pixels, stride); const out = blankPixels(stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + (width - 1 - x)] = source[y * stride + x] || null; } return out; } function mirrorEditorDepthHorizontal(values, stride, width, height) { const source = normalizeDepthPixels(values, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + (width - 1 - x)] = source[y * stride + x] || 0; } return out; } function normalizeEditorOrientationForSave() { const stride = editorSize; const dynamicLeftCanvas = roleToCategory(currentRole()) === 'dynamic' && editingSide === 'left'; const rightPixels = dynamicLeftCanvas ? mirrorEditorPixelsHorizontal(editorPixels, stride, editorWidth, editorHeight) : normalizePixels(editorPixels, stride); const normalizedDepth = normalizeDepthPixels(depthPixels, stride); const orientedDepth = dynamicLeftCanvas ? mirrorEditorDepthHorizontal(normalizedDepth, stride, editorWidth, editorHeight) : normalizedDepth; const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal(lightPixels, editorWidth, editorHeight) : lightPixels.map((p) => ({ ...p })); const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal(particlePixels, editorWidth, editorHeight) : particlePixels.map((p) => ({ ...p })); const orientedParticleConfig = normalizeParticleConfig({ ...particleConfig, enabled: orientedParticles.length > 0 }, stride); return { rightPixels, depthPixels: orientedDepth, lightPixels: orientedLights, particlePixels: orientedParticles, particleConfig: orientedParticleConfig, mirroredFromLeft: dynamicLeftCanvas, width: editorWidth, height: editorHeight, stride }; } function getBottomShiftRect(pixels, stride, width, height) { let maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) if (pixels[y * stride + x]) maxY = Math.max(maxY, y); } return maxY < 0 ? 0 : height - 1 - maxY; } function shiftPixelsVerticalRect(pixels, stride, width, height, shift) { const out = blankPixels(stride); const source = normalizePixels(pixels, stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const ny = y + shift; if (ny >= 0 && ny < height) out[ny * stride + x] = source[y * stride + x] || null; } } return out; } function shiftDepthPixelsVerticalRect(pixels, stride, width, height, shift) { const source = normalizeDepthPixels(pixels, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const ny = y + shift; if (ny >= 0 && ny < height) out[ny * stride + x] = source[y * stride + x] || 0; } } return out; } function trimEditorStateForSave(aligned) { const { stride, width, height } = aligned; let minX = width, minY = height, maxX = -1, maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (!aligned.rightPixels[y * stride + x]) continue; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } if (maxX < 0) return null; const outW = maxX - minX + 1; const outH = maxY - minY + 1; const outPixels = blankPixels(outW, outH); const outDepth = Array(outW * outH).fill(0); for (let y = 0; y < outH; y++) { for (let x = 0; x < outW; x++) { const src = (minY + y) * stride + (minX + x); const dst = y * outW + x; outPixels[dst] = aligned.rightPixels[src] || null; outDepth[dst] = aligned.depthPixels[src] || 0; } } const lightPixels = sanitizeLightPixels( aligned.lightPixels.map((p) => ({ ...p, x: p.x - minX, y: p.y - minY })), outPixels, outW, outW, outH, selectedColorCode, false ); const particlePixels = aligned.particlePixels .map((p) => ({ ...p, x: p.x - minX, y: p.y - minY })) .filter((p) => p.x >= 0 && p.y >= 0 && p.x < outW && p.y < outH && outPixels[p.y * outW + p.x]); return { width: outW, height: outH, size: Math.max(outW, outH), rightPixels: outPixels, depthPixels: outDepth, lightPixels, particlePixels, particleConfig: normalizeParticleConfig({ ...aligned.particleConfig, enabled: particlePixels.length > 0 }, Math.max(outW, outH)), door: aligned.door ? { x: clamp(aligned.door.x - minX, 0, outW - 1), y: clamp(aligned.door.y - minY, 0, outH - 1) } : null, mirroredFromLeft: aligned.mirroredFromLeft, crop: { x: minX, y: minY, w: outW, h: outH } }; } function alignEditorStateToBottom() { const oriented = normalizeEditorOrientationForSave(); const shift = getBottomShiftRect(oriented.rightPixels, oriented.stride, oriented.width, oriented.height); const aligned = { ...oriented, rightPixels: shiftPixelsVerticalRect(oriented.rightPixels, oriented.stride, oriented.width, oriented.height, shift), depthPixels: shiftDepthPixelsVerticalRect(oriented.depthPixels, oriented.stride, oriented.width, oriented.height, shift), lightPixels: oriented.lightPixels .map((p) => ({ ...p, y: p.y + shift })) .filter((p) => p.x >= 0 && p.x < oriented.width && p.y >= 0 && p.y < oriented.height), particlePixels: shiftParticlePointsVertical(oriented.particlePixels, oriented.width, oriented.height, shift), door: { x: clamp(doorPixel.x, 0, oriented.width - 1), y: clamp(doorPixel.y + shift, 0, oriented.height - 1) }, mirroredFromLeft: oriented.mirroredFromLeft }; const trimmed = trimEditorStateForSave(aligned); return trimmed || aligned; } function getBottomShift(pixels, size) { let maxY = -1; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (pixels[y * size + x]) maxY = Math.max(maxY, y); } } return maxY < 0 ? 0 : size - 1 - maxY; } function shiftPixelsVertical(pixels, size, shift) { if (!shift) return normalizePixels(pixels, size); const out = blankPixels(size); const source = normalizePixels(pixels, size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const ny = y + shift; if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || null; } } return out; } function shiftDepthPixelsVertical(pixels, size, shift) { const source = normalizeDepthPixels(pixels, size); if (!shift) return source; const out = Array(size * size).fill(0); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const ny = y + shift; if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || 0; } } return out; } function buildEditorAssetRecord() { const role = currentRole(); const category = roleToCategory(role); const subtype = roleToSubtype(role); const existing = editingAssetId ? findAsset(editingAssetId) : null; const paintedDots = countPixels(editorPixels); if (paintedDots < 10) { toast('Draw at least 10 pixels before saving.'); return null; } if (!existing && state.assets.length >= PHASE5_GUARDRAILS.maxAssets) { toast(`Asset limit reached (${PHASE5_GUARDRAILS.maxAssets}). Delete or hide unused assets before saving more.`); return null; } const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`; const aligned = alignEditorStateToBottom(); const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); const savedWidth = aligned.width || savedSize; const savedHeight = aligned.height || savedSize; const encodedRight = encodePixels(aligned.rightPixels, savedWidth, savedHeight); const pixelBlob = buildPixelBlob(encodedRight, savedWidth, savedHeight); const asset = { id: existing?.id || uid(), name: cleanWorkText(name, 'Untitled'), category, subtype, size: savedSize, width: savedWidth, height: savedHeight, blobId: pixelBlob.id, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now(), author: existing?.author || state.authorName || 'Local Artist', ownerAccountId: normalizeOwnerAccountId(existing?.ownerAccountId, currentAccountId()), version: Number(existing?.version || 0) + 1, parentAssetId: existing ? existing.parentAssetId : editParentId, originalAssetId: existing ? existing.originalAssetId : editOriginalId, pixels: encodedRight, faces: category === 'dynamic' ? { right: encodedRight, left: 'mirror' } : null, meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, savedWidth, aligned.particlePixels, aligned.rightPixels, savedHeight) }; if (asset.meta && category === 'dynamic') asset.meta.frontSide = frontSide; asset.contentHash = computeAssetContentHash(asset); return { asset, pixelBlob, existing, aligned }; } function cacheAssetRecord(asset, pixelBlob) { if (Phase2Sync?.cachePixelBlobs && pixelBlob) { Phase2Sync.cachePixelBlobs([pixelBlob]).catch((error) => console.warn('Phase 2 pixel blob cache failed.', error)); } if (Phase2Sync?.cacheAssets && asset) { Phase2Sync.cacheAssets([asset]).catch((error) => console.warn('Phase 2 asset metadata cache failed.', error)); } } function persistAssetRecord(record) { if (!record?.asset) return null; const { asset, pixelBlob, existing, aligned } = record; if (existing) { const index = state.assets.findIndex((a) => a.id === existing.id); if (index >= 0) state.assets[index] = asset; toast(`${asset.name} updated.`); } else { state.assets.unshift(asset); toast(aligned?.mirroredFromLeft ? `${asset.name} saved. Left-facing canvas was mirrored into right-facing movement.` : `${asset.name} saved.`); } selectedAssetId = asset.id; cacheAssetRecord(asset, pixelBlob); return asset; } function queueSharedAssetSave(asset, options = {}) { if (!asset || !isSharedWorld()) return false; ensureLocalAccount('save'); const actor = currentAccountId(); if (!actor) return false; const ownedAsset = normalizeAsset({ ...asset, ownerAccountId: actor, author: state.account?.name || state.authorName || asset.author }); queueSharedCommand(makeSharedCommand('asset.create', { asset: ownedAsset }), { toast: options.toast === true }); syncSharedWorld({ silent: true, reason: 'asset-save' }); return true; } function saveAssetFromEditor() { if (isSharedWorld()) ensureLocalAccount('save'); const record = buildEditorAssetRecord(); if (!record) return null; const { asset, existing } = record; const duplicate = existing ? null : findEquivalentCollectionAsset(asset); if (duplicate) { selectedAssetId = duplicate.id; queueSharedAssetSave(duplicate, { toast: false }); toast(`${duplicate.name} is already in Collection.`); return duplicate; } persistAssetRecord(record); if (isSharedWorld()) queueSharedAssetSave(asset, { toast: false }); else recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); editParentId = null; editOriginalId = null; editingAssetId = null; rebuildWorldIndex(); saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary({ preserveScroll: activeTabName === 'library' }); updateSelectedLabel(); els.lineageNote.textContent = 'Saved as a permanent collection work. Island placement is a separate temporary exhibition object.'; return asset; } function saveAndPlaceFromEditor() { ensureLocalAccount('save-place'); const asset = saveAssetFromEditor(); if (!asset) return; selectedAssetId = asset.id; placementPreview = null; clearWorldSelection(false); setMode('place'); setDrawerOpen(false); syncPlacementUi(); toast('Saved to Collection. Left-click a tile to choose a position, then right-click or PLACE HERE to publish.'); } function buildEditorAssetPreview() { const role = currentRole(); const category = roleToCategory(role); const subtype = roleToSubtype(role); const size = editorSize; if (countPixels(editorPixels) < 10) { toast('Draw at least 10 pixels before checking on the island.'); return null; } const aligned = alignEditorStateToBottom(); const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); const savedWidth = aligned.width || savedSize; const savedHeight = aligned.height || savedSize; const encodedRight = encodePixels(aligned.rightPixels, savedWidth, savedHeight); const pixelBlob = buildPixelBlob(encodedRight, savedWidth, savedHeight); const asset = { id: `preview:${Date.now()}`, name: (els.assetName.value || '').trim() || 'Preview work', category, subtype, size: savedSize, width: savedWidth, height: savedHeight, blobId: pixelBlob.id, author: state.authorName || 'Local Artist', pixels: encodedRight, faces: category === 'dynamic' ? { right: encodedRight, left: 'mirror' } : null, meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, savedWidth, aligned.particlePixels, aligned.rightPixels, savedHeight) }; if (asset.meta && category === 'dynamic') asset.meta.frontSide = frontSide; return asset; } function checkCurrentEditorOnIsland() { const asset = buildEditorAssetPreview(); if (!asset) return; placementPreview = { asset, x: null, y: null }; setDrawerOpen(false); setMode('place'); syncPlacementUi(); toast('Left-click a valid tile to preview. Right-click or PLACE HERE to confirm a saved work.'); } function cancelPlacementPreview(reopenDrawer = true) { const hadPreview = !!placementPreview; placementPreview = null; syncPlacementUi(); if (reopenDrawer) setDrawerOpen(true); else syncPlacementUi(); if (hadPreview) render(); } function confirmPreviewPlacement() { if (!placementPreview?.asset || placementPreview.x == null || placementPreview.y == null) { toast('Click a tile first.'); return; } let assetId = placementPreview.savedAssetId || placementPreview.asset.id; if (!findAsset(assetId) && String(assetId).startsWith('preview:')) { const saved = saveAssetFromEditor(); if (saved) assetId = saved.id; } if (!findAsset(assetId)) { toast('Saved asset is missing. Back to canvas and save again.'); return; } selectedAssetId = assetId; const { x, y } = placementPreview; placementPreview = null; syncPlacementUi(); publishSelectedAt(x, y); setMode('inspect'); updateSelectionBubble(performance.now()); } function newAsset() { editParentId = null; editOriginalId = null; els.assetName.value = ''; els.assetCategory.value = 'human'; staticKind = 'nature'; dynamicKind = 'human'; editingSide = 'right'; frontSide = 'right'; lightPixels = []; particlePixels = []; particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; setupEditor(8, blankPixels(8), null); clearEditorHistory(); refreshCategoryUI(); els.lineageNote.textContent = ''; } function hydrateAuthorUI() { state.account = normalizeAccount(state.account); state.authorName = state.account?.name || state.authorName || 'Local Artist'; if (els.authorName) els.authorName.value = state.authorName; updateAccountUI(); } function normalizeAccount(account) { if (!account?.createdAt) return null; const id = String(account.id || `px-${uid()}`).replace(/^local:/, 'px-'); const password = String(account.password || account.pass || generateLocalPassword()); const name = String(account.name || id).trim() || id; return { id, name, password, createdAt: Number(account.createdAt) || Date.now() }; } function generateAccountId() { return `px-${uid().replace(/[^a-z0-9]/gi, '').slice(0, 10)}`; } function generateLocalPassword() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; if (globalThis.crypto?.getRandomValues) { const bytes = new Uint8Array(12); globalThis.crypto.getRandomValues(bytes); return [...bytes].map((b) => alphabet[b % alphabet.length]).join(''); } return Array.from({ length: 12 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(''); } function createLocalAccount(silent = false) { if (!state.account?.createdAt) { const id = generateAccountId(); state.account = { id, name: id, password: generateLocalPassword(), createdAt: Date.now() }; state.authorName = id; } else { const name = (els.authorName?.value || state.account.name || state.account.id).trim() || state.account.id; state.account = normalizeAccount({ ...state.account, name, password: (els.accountPass?.value || state.account.password || '').trim() || generateLocalPassword() }); state.authorName = state.account.name; } saveState(); updateAccountUI(); renderLibrary(); if (!silent) toast('Local account generated. Change the password after creation.'); return state.account; } function ensureLocalAccount(reason = 'publish') { if (state.account?.createdAt) { state.account = normalizeAccount(state.account); state.authorName = state.account.name; updateAccountUI(); return state.account; } const account = createLocalAccount(true); toast(reason === 'save-place' ? 'Local account auto-generated. You can rename it and should change the password.' : 'Local account auto-generated for publishing. Change the password after creation.'); return account; } function updateAccountUI() { if (!els.accountNote) return; if (!state.account?.createdAt) { if (els.accountId) els.accountId.value = ''; if (els.authorName) els.authorName.value = state.authorName || 'Local Artist'; if (els.accountPass) els.accountPass.value = ''; els.accountNote.textContent = 'Publish creates a local account for island publishing.'; if (els.createAccount) els.createAccount.hidden = false; updatePublishQuotaUI(); return; } state.account = normalizeAccount(state.account); state.authorName = state.account.name; if (els.accountId) els.accountId.value = state.account.id; if (els.authorName) els.authorName.value = state.account.name; if (els.accountPass) els.accountPass.value = state.account.password || ''; const day = getAccountAgeMs() < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+'; els.accountNote.textContent = `Account ${day}.`; if (els.createAccount) els.createAccount.hidden = true; updatePublishQuotaUI(); } function updatePublishQuotaUI() { const quota = getPublishQuotaStatus(); const pendingText = quota.pending ? ` / ${quota.pending} pending` : ''; const text = quota.accountRequired ? 'Publish: account needed' : `Publish: ${quota.remaining}/${quota.limit} left${pendingText}`; [els.drawQuotaBadge, els.finishQuotaBadge].filter(Boolean).forEach((badge) => { badge.textContent = text; badge.classList.toggle('quotaEmpty', !quota.accountRequired && quota.remaining <= 0); }); } function focusAssetInWorld(asset, options = {}) { const placed = state.placed.find((p) => p.assetId === asset.id); const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id); const target = placed ? { x: placed.x + .5, y: placed.y + .5 } : dyn ? { x: dyn.homeX + .5, y: dyn.homeY + .5 } : null; if (!target) { selectedObject = null; setMode('place'); toast('Selected. Place it on the map.'); return; } const pos = tileToWorld(target.x, target.y); pos.y -= getLiftAtCoord(target.x, target.y); view.x = cw / 2 - pos.x * view.zoom; view.y = ch / 2 - pos.y * view.zoom; if (placed) selectWorldObject('static', placed.id, asset.id, performance.now(), { renderLibrary: options.renderLibrary !== false }); else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now(), { renderLibrary: options.renderLibrary !== false }); setMode('inspect'); } function renderLibrary(options = {}) { syncLibraryStateFromLegacy(); const preserveScroll = options.preserveScroll ?? Boolean(els.drawer?.classList.contains('open')); const scrollState = preserveScroll ? (options.scrollState || captureLibraryScrollState()) : null; els.assetList.replaceChildren(); if (els.likedCodex) { els.likedCodex.replaceChildren(); els.likedCodex.hidden = true; } const visibleAssets = state.assets.filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id]); renderLibraryToolbar(visibleAssets); if (!visibleAssets.length) { renderCollectionTabs({ mine: [], others: [], favorite: [] }); els.assetList.append(makeLibraryEmptyNote('No visible assets.')); renderHiddenAssets(); if (preserveScroll) restoreLibraryScrollState(scrollState); return; } const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter; const matchesSearch = (asset) => { const query = librarySearch.trim().toLowerCase(); if (!query) return true; return [asset.name, asset.author, asset.subtype, asset.category].some((value) => String(value || '').toLowerCase().includes(query)); }; const isMyAsset = (asset) => isCurrentUserAsset(asset); const mine = visibleAssets.filter((asset) => isMyAsset(asset) && matchesFilter(asset) && matchesSearch(asset)); const others = visibleAssets.filter((asset) => !isMyAsset(asset) && matchesFilter(asset) && matchesSearch(asset)); const favorite = getLikedAssets().filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id] && matchesFilter(asset) && matchesSearch(asset)); const groups = { mine, others, favorite }; if (!groups[libraryViewTab]) libraryViewTab = 'mine'; if (libraryViewTab === 'mine' && mine.length === 0 && others.length > 0) libraryViewTab = 'others'; uiState.library.viewTab = libraryViewTab; renderCollectionTabs(groups); if (libraryViewTab === 'mine') addLibrarySection('My works', mine, 'No assets in this tab.'); else if (libraryViewTab === 'others') addLibrarySection('Others', others, 'No assets in this tab.'); else addLibrarySection('Favorite', favorite, 'Works you upvote appear here.'); renderHiddenAssets(); if (preserveScroll) restoreLibraryScrollState(scrollState); syncLibraryStateFromLegacy(); } function renderLibraryToolbar(visibleAssets) { if (!els.assetList) return; const toolbar = document.createElement('div'); toolbar.className = 'collectionToolbar'; const summary = document.createElement('div'); summary.className = 'collectionSummary'; const placedCount = visibleAssets.filter((asset) => getAssetPlacementStats(asset.id).total > 0).length; summary.textContent = `${visibleAssets.length} works / ${placedCount} on island`; toolbar.append(summary); const search = document.createElement('input'); search.className = 'collectionSearch'; search.type = 'search'; search.placeholder = 'Search collection'; search.value = librarySearch; search.addEventListener('input', () => { librarySearch = search.value || ''; uiState.library.search = librarySearch; const caret = search.selectionStart || librarySearch.length; const scrollState = captureLibraryScrollState(); renderLibrary({ preserveScroll: true, scrollState }); requestAnimationFrame(() => { restoreLibraryScrollState(scrollState); const nextSearch = els.assetList?.querySelector('.collectionSearch'); if (!nextSearch) return; nextSearch.focus?.({ preventScroll: true }); nextSearch.setSelectionRange?.(caret, caret); }); }); toolbar.append(search); const chips = document.createElement('div'); chips.className = 'collectionFilters'; for (const filter of ['all', 'human', 'animal', 'bird', 'fish', 'nature', 'building', 'ship', 'other']) { const button = document.createElement('button'); button.type = 'button'; button.className = 'collectionFilter'; button.classList.toggle('active', libraryFilter === filter); button.textContent = filter === 'all' ? 'All' : cap(filter); button.addEventListener('click', () => { const scrollState = captureLibraryScrollState(); libraryFilter = filter; uiState.library.filter = libraryFilter; libraryGridScrollTop = 0; scrollState.gridTop = 0; renderLibrary({ preserveScroll: true, scrollState }); }); chips.append(button); } toolbar.append(chips); els.assetList.append(toolbar); } function renderCollectionTabs(groups) { const tabs = document.createElement('div'); tabs.className = 'collectionViewTabs tabs'; const labels = [ ['mine', 'My works'], ['others', 'Others'], ['favorite', 'Favorite'] ]; for (const [key, label] of labels) { const button = document.createElement('button'); button.type = 'button'; button.className = 'tab collectionViewTab'; button.classList.toggle('active', libraryViewTab === key); button.textContent = `${label} ${groups[key]?.length ?? 0}`; button.addEventListener('click', () => { const scrollState = captureLibraryScrollState(); libraryViewTab = key; uiState.library.viewTab = libraryViewTab; libraryGridScrollTop = 0; scrollState.gridTop = 0; renderLibrary({ preserveScroll: true, scrollState }); }); tabs.append(button); } els.assetList.append(tabs); } function makeLibraryEmptyNote(text) { const empty = document.createElement('div'); empty.className = 'libraryEmptyNote'; empty.textContent = text; return empty; } function addLibrarySection(title, assets, emptyText = 'No assets in this genre.') { const heading = document.createElement('div'); heading.className = 'librarySectionTitle'; heading.textContent = `${title} (${assets.length})`; heading.title = ''; els.assetList.append(heading); if (!assets.length) { els.assetList.append(makeLibraryEmptyNote(emptyText)); return; } const grid = document.createElement('div'); grid.className = 'assetSectionGrid'; grid.addEventListener('scroll', () => { libraryGridScrollTop = grid.scrollTop; uiState.library.scroll.gridTop = grid.scrollTop; }, { passive: true }); for (const asset of assets) grid.append(makeAssetCard(asset)); els.assetList.append(grid); } function restoreLibraryScrollState(snapshot) { const next = snapshot || uiState.library.scroll || { bodyTop: 0, gridTop: 0 }; const bodyTop = Math.max(0, Number(next.bodyTop) || 0); const gridTop = Math.max(0, Number(next.gridTop) || 0); uiState.library.scroll = { bodyTop, gridTop }; syncLegacyFromLibraryState(); const token = ++uiState.library.restoreToken; libraryScrollFrameToken = token; const apply = () => { if (token !== uiState.library.restoreToken) return; const body = drawerScroller(); const grid = els.assetList?.querySelector('.assetSectionGrid'); if (body && Math.abs(body.scrollTop - bodyTop) > 1) body.scrollTop = bodyTop; if (grid && Math.abs(grid.scrollTop - gridTop) > 1) grid.scrollTop = gridTop; }; apply(); requestAnimationFrame(apply); requestAnimationFrame(() => requestAnimationFrame(apply)); setTimeout(apply, 0); setTimeout(apply, 60); } function withLibraryScrollPreserved(mutator) { const snapshot = captureLibraryScrollState(); mutator?.(snapshot); restoreLibraryScrollState(snapshot); return snapshot; } function getAssetPlacementStats(assetId) { const confirmed = state.placed.filter((p) => p.assetId === assetId).length + state.dynamicSummons.filter((p) => p.assetId === assetId).length; const visuals = Object.values(state.serverSync?.pendingObjectVisuals || {}).filter((entry) => entry?.assetId === assetId || entry?.object?.assetId === assetId); const failed = visuals.filter((entry) => entry?.status === 'failed').length; const pending = Math.max(0, visuals.length - failed); return { confirmed, pending, failed, total: confirmed + pending + failed }; } function makeAssetCard(asset) { const card = document.createElement('article'); const expanded = asset.id === selectedAssetId; card.className = `assetCard${expanded ? ' selected expanded' : ''}`; const preview = document.createElement('canvas'); preview.className = 'assetPreview'; preview.width = 64; preview.height = 64; drawPreview(preview, asset); const meta = document.createElement('div'); meta.className = 'assetMeta'; const title = document.createElement('strong'); title.textContent = asset.name; meta.append(title); const placementStats = getAssetPlacementStats(asset.id); const placementCount = placementStats.total; const isPlaced = placementCount > 0; const statusRow = document.createElement('div'); statusRow.className = 'assetStatusRow'; const roleTag = document.createElement('span'); roleTag.className = 'assetStatus role'; roleTag.textContent = cap(subtypeToRole(asset)); const placeTag = document.createElement('span'); placeTag.className = `assetStatus ${isPlaced ? 'placed' : 'stored'}${placementStats.pending && !placementStats.confirmed ? ' validating' : ''}${placementStats.failed && !placementStats.confirmed ? ' failed' : ''}`; placeTag.textContent = isPlaced ? (placementStats.confirmed ? (placementCount > 1 ? `On island ${placementCount}` : 'On island') : (placementStats.failed ? 'Publish failed' : 'Publishing')) : 'Stored'; statusRow.append(roleTag, placeTag); meta.append(statusRow); card.addEventListener('click', (event) => { event.preventDefault(); const scrollState = captureLibraryScrollState(); selectedAssetId = expanded ? null : asset.id; uiState.selectedAssetId = selectedAssetId || null; updateSelectedLabel(); renderLibrary({ preserveScroll: true, scrollState }); if (selectedAssetId) focusAssetInWorld(asset, { renderLibrary: false }); restoreLibraryScrollState(scrollState); }); if (expanded) { const lineage = asset.parentAssetId ? ' / derivative' : ''; const span1 = document.createElement('span'); span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${assetWidth(asset)}x${assetHeight(asset)}${lineage}`; const span2 = document.createElement('span'); span2.textContent = `Author: ${displayAssetAuthor(asset)} / Remixed: ${getRemixCount(asset.id)}`; meta.append(span1, span2); const actions = document.createElement('div'); actions.className = 'assetActions'; const assetVotes = getAssetVoteCounts(asset.id); const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0; const up = makeButton(`Up ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); const down = makeButton(`Down ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); }); up.classList.toggle('activeVote', assetPreviousVote > 0); down.classList.toggle('activeVote', assetPreviousVote < 0); up.classList.toggle('mutedVote', assetPreviousVote < 0); down.classList.toggle('mutedVote', assetPreviousVote > 0); const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); hide.classList.toggle('mutedAction', assetPreviousVote >= 0); const isMine = isCurrentUserAsset(asset); const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); }); const edit = isMine ? makeButton('Edit', (event) => { event?.stopPropagation?.(); editOriginalAsset(asset); }) : null; const move = isMine ? makeButton('Place', (event) => { event?.stopPropagation?.(); const scrollState = captureLibraryScrollState(); selectedAssetId = asset.id; updateSelectedLabel(); renderLibrary({ preserveScroll: true, scrollState }); setMode('place'); setDrawerOpen(false); syncPlacementUi(); toast('Left-click a tile to choose a position, then right-click or PLACE HERE to publish another copy.'); }) : null; const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); actions.append(up, down, hide, remix); if (edit) actions.append(edit); if (move) actions.append(move); if (isMine) actions.append(del); meta.append(actions); } else { const mini = document.createElement('span'); mini.textContent = `${cap(asset.subtype)} / ${assetWidth(asset)}x${assetHeight(asset)}`; meta.append(mini); if (isCurrentUserAsset(asset)) { const quickActions = document.createElement('div'); quickActions.className = 'assetActions'; const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); quickActions.append(del); meta.append(quickActions); } } card.append(preview, meta); return card; } function getLikedAssets() { const voter = currentVoterKey(); const likedIds = new Set(); for (const [assetId, votes] of Object.entries(state.assetVotes || {})) { if ((votes?.voters || {})[voter] > 0) likedIds.add(assetId); } for (const [objectId, votes] of Object.entries(state.objectVotes || {})) { if ((votes?.voters || {})[voter] > 0) { const obj = [...(state.placed || []), ...(state.dynamicSummons || [])].find((item) => item.id === objectId); if (obj?.assetId) likedIds.add(obj.assetId); } } return [...likedIds].map(findAsset).filter(Boolean); } function renderLikedCodex() { if (!els.likedCodex) return; const liked = getLikedAssets().slice(0, 12); if (!liked.length) { els.likedCodex.innerHTML = '