diff --git a/README.md b/README.md index 10f2337..1e0dc74 100644 --- a/README.md +++ b/README.md @@ -114,3 +114,76 @@ Open `index.html` in a modern browser. No build step or third-party dependency i - Coast foam uses two slow-rotating porous bluish-white layers. - Depth now affects phase-aware sprite shading for sun/moon exposure. - Added toggles for lights, particles, and the day-night cycle. + + +## Phase 5 UI / particle follow-up +- Coastal foam no longer uses cut-out holes. +- Walking particles are less frequent and use more visibly varied colors. +- Remix always creates a new derivative asset; Edit updates the user's own original asset. +- Advanced Draw now includes configurable particle emitter cells for any asset. +- PNG export/import moved to the Data tab. +- Nudge arrow buttons under the canvas were removed; keyboard arrow nudging remains. +- UI text is larger overall, while palette color-code labels keep their small size. + +## Rotation policy build + +This build adds count-based island rotation. + +- Default island display cap: 250 objects. +- The local display cap is adjustable from Data > Visual settings. +- Objects are not deleted when the cap is exceeded; the effective oldest objects are omitted from the island display. +- Upvotes delay rotation-out by slot adjustment. Downvotes advance rotation-out. +- Local publish / republish quota: 5 objects per author per rolling hour. +- Replacing or republishing a rotation-hidden object updates its `publishedAt` and consumes one quota slot. + +Server-side periodic rotation code is in `server/rotation_worker.py`. + +Example: + +```bash +python server/rotation_worker.py world.json --write +``` + +It performs: + +1. Permanent-hide marking for extremely downvoted objects. +2. Randomly samples 50 hidden historical objects. +3. Republishes the top 20 by upvote count. +4. Reapplies the active display cap. +5. Adds permanent-hidden objects to `adminReviewQueue` for deletion checks. + + +## Local-only rotation note +This build does not call a server from the browser. The island display cap is applied client-side, and objects over the cap are kept in local save but not drawn. Server recirculation / permanent hiding only happens when `server/rotation_worker.py` is run against exported world JSON. + +## Exhibition Policy Revision + +This build separates **Asset** and **PlacementObject** more explicitly. + +- Asset: a durable library work. Remix only copies pixels and lineage into the canvas. +- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Library. +- Edit has been removed. To revise a work, remix/copy it, save a new Asset, then delete the old one if desired. +- Publishing requires a prototype local account. The first 24 hours allow 5 public placements/hour; day 2+ allows 10/hour. +- The island exhibition has 250 visible slots: 150 newest slots and 100 random revival slots. +- Upvote rank effect is capped at +50 votes. +- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Library. +- Server moderation can mark `permanent_hidden` / `violation_hidden`; these are hidden from authors and viewers. Admins can restore them with the Python worker. +- Particles are disabled offscreen/when zoomed out and capped at 200 active particles. + +### Server worker + +```bash +python server/rotation_worker.py world.json --write +python server/rotation_worker.py world.json --restore object123 --write +python server/rotation_worker.py world.json --hide-violation object123 --write +``` + +The Python worker is the authoritative production policy. The browser mirrors it only for local prototype use. + +### Compression lab + +```bash +python server/compression_lab.py exported_world.json +``` + +Use this to compare minified JSON, gzip, zlib, and Brotli when available. Production should use the compact asset codec plus HTTP gzip/Brotli rather than hand-rolled custom compression first. diff --git a/app.js b/app.js index 35a64b6..e42deda 100644 --- a/app.js +++ b/app.js @@ -3,8 +3,8 @@ console.info('Pixel Island Summoner loaded'); - const STORAGE_KEY = 'pixel-island-summoner:phase5c'; - const SAVE_SCHEMA = 9; + const STORAGE_KEY = 'pixel-island-summoner:phase5d'; + const SAVE_SCHEMA = 10; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -28,7 +28,7 @@ const EditorActions = MODULES.EditorActions || null; const Phase2Sync = MODULES.Phase2Sync || null; const EDITOR_HISTORY_LIMIT = 80; - const PHASE5_GUARDRAILS = { maxAssets: 160, maxWorldObjects: 220, maxReports: 200 }; + 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 $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); @@ -37,7 +37,7 @@ const lerp = (a, b, t) => a + (b - a) * t; function defaultVisualSettings() { - return { enableLights: true, enableParticles: true, enableDayNight: true }; + return { enableLights: true, enableParticles: true, enableDayNight: true, localDisplayLimit: PHASE5_GUARDRAILS.defaultDisplayLimit }; } function visualSettings() { @@ -50,6 +50,7 @@ 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() { @@ -89,6 +90,7 @@ canvas: $('worldCanvas'), openEditor: $('openEditor'), closeEditor: $('closeEditor'), + placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), createAccount: $('createAccount'), accountNote: $('accountNote'), drawer: $('studioDrawer'), tabs: [...document.querySelectorAll('.tab')], panels: [...document.querySelectorAll('.tabPanel')], @@ -98,7 +100,7 @@ selectedAssetName: $('selectedAssetName'), tileInfo: $('tileInfo'), toast: $('toast'), - selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), + selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleEdit: $('bubbleEdit'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), @@ -114,14 +116,14 @@ sideSwitcher: $('sideSwitcher'), editRight: $('editRight'), editLeft: $('editLeft'), paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), - toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), + toolLight: $('toolLight'), toolParticle: $('toolParticle'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), - settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), - lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), + settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'), + lineageNote: $('lineageNote'), assetList: $('assetList'), likedCodex: $('likedCodex'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'), exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats'), guardrailStats: $('guardrailStats'), validateWorld: $('validateWorld'), exportModerationReport: $('exportModerationReport'), clearReports: $('clearReports'), - settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), + settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), displayLimit: $('displayLimit'), rotationStats: $('rotationStats'), reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName') }; @@ -164,6 +166,7 @@ let staticKind = 'nature'; let dynamicKind = 'human'; let lightPixels = []; + let particlePixels = []; let doorPixel = { x: 8, y: 15 }; let editParentId = null; let editOriginalId = null; @@ -196,6 +199,7 @@ let selectionGesture = null; let shapeGesture = null; let shapePreview = null; + let placementPreview = null; function bootstrap() { resizeCanvas(); @@ -205,6 +209,7 @@ wireUI(); renderPalette(); hydrateAuthorUI(); + updateAccountUI(); refreshCategoryUI(); setupEditor(8, blankPixels(8), null); clearEditorHistory(); @@ -214,6 +219,7 @@ updateSyncStats(); updateGuardrailStats(); hydrateVisualSettingsUI(); + updateRotationStats(); scheduleFrame(); } @@ -232,10 +238,16 @@ els.authorName?.addEventListener('input', () => { state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; + if (state.account) state.account.name = state.authorName; saveState(); + updateAccountUI(); renderLibrary(); }); + els.createAccount?.addEventListener('click', () => createLocalAccount()); + els.confirmPreviewPlace?.addEventListener('click', confirmPreviewPlacement); + els.backToCanvas?.addEventListener('click', cancelPlacementPreview); + els.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; saveState(); @@ -255,6 +267,15 @@ 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'))); @@ -280,6 +301,7 @@ setupEditor(nextSize, resizedRight, null, resizedDepth); doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); + particlePixels = particlePixels.filter((p) => p.x < nextSize && p.y < nextSize); resetEditorView(); return true; }); @@ -321,13 +343,14 @@ els.toggleAdvanced?.addEventListener('click', () => { advancedDraw = !advancedDraw; toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.depthClear, !advancedDraw); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced.classList.toggle('active', advancedDraw); - if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush'); + if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush'); drawEditor(); }); els.clearPaint.addEventListener('click', () => { @@ -336,6 +359,7 @@ editorLeftPixels = mirrorPixels(editorPixels, editorSize); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; + particlePixels = []; editorSelection = null; return true; }); @@ -357,7 +381,7 @@ els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); - els.bubbleReport?.addEventListener('click', () => openReportDialog()); + 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(); }); @@ -377,6 +401,7 @@ els.saveAsset.addEventListener('click', saveAssetFromEditor); els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); + els.checkOnIsland?.addEventListener('click', checkCurrentEditorOnIsland); els.newAsset.addEventListener('click', newAsset); els.exportData.addEventListener('click', exportData); els.importData.addEventListener('click', importData); @@ -457,11 +482,12 @@ toggleHidden(els.staticSettingsPanel, !isStatic); toggleHidden(els.dynamicSettingsPanel, true); toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); - if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth')) setPaintTool('brush'); + 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'; @@ -493,17 +519,21 @@ if (roleToCategory(role) === 'static') { const lightCount = lightPixels.length; const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light'; + const particleCount = particlePixels.length; + const particleText = particleCount ? `${particleCount} particle cell${particleCount === 1 ? '' : 's'}` : 'no particles'; const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; - els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`; + els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText} / ${particleText}${doorText}.`; } else { - els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`; + const particleCount = particlePixels.length; + const particleText = particleCount ? ` / ${particleCount} particle cell${particleCount === 1 ? '' : 's'}` : ''; + els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is auto-mirrored${particleText}.`; } } function setPaintTool(tool) { paintTool = tool; - [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, 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, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); + [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 pixels. Erasing also clears light markers on that cell.', @@ -513,6 +543,7 @@ rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', select: 'Drag a rectangle to select pixels, then drag or nudge the selection.', light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', + particle: 'Paint particle emitter cells with the selected palette color. Hold Shift or right-click to erase emitters.', 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/Clear buttons; Shift clears.' }; @@ -702,12 +733,173 @@ } + function getLocalDisplayLimit() { + const settings = visualSettings(); + return clampInt(settings.localDisplayLimit, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); + } + + function getObjectRecord(kind, objectId) { + if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null; + return state.dynamicSummons.find((p) => p.id === objectId) || null; + } + + function getObjectPublicAt(kind, object) { + return 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 allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])]; + return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation'); + } + + function isServerSuppressedObject(object) { + return ['archived', 'permanent_hidden', 'violation_hidden'].includes(object?.status); + } + + function getObjectRotationEntry(kind, object, baseIndex = 0) { + const objectId = object?.id; + const votes = getObjectVoteCounts(objectId); + const rawUp = Number(votes.up) || 0; + const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap); + const down = Number(votes.down) || 0; + const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots; + const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots; + return { + kind, + object, + id: objectId, + assetId: object?.assetId, + publicAt: getObjectPublicAt(kind, object), + baseIndex, + up, + rawUp, + down, + effectiveSlot: baseIndex + delay - advance + }; + } + + 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 = '') { + 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(); + 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; + 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 : []; + const author = currentVoterKey(); + const windowMs = 60 * 60 * 1000; + state.publishLog = state.publishLog.filter((entry) => now - Number(entry.at || 0) < windowMs * 24); + const used = state.publishLog.filter((entry) => entry.author === author && now - Number(entry.at || 0) < windowMs).length; + const limit = currentPublishLimit(now); + return { used, limit, remaining: Math.max(0, limit - used), accountRequired: !state.account?.createdAt }; + } + + function canPublishObject(action = 'publish') { + 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.`); + return false; + } + return true; + } + + function recordObjectPublish(kind, object, action = 'publish') { + 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 }); + state.publishLog = state.publishLog.slice(-300); + } + + function getDrawableItems(time = performance.now(), viewport = null) { const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); const items = []; for (const placed of state.placed) { const asset = findAsset(placed.assetId); - if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue; + if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id] || isPermanentlyHiddenObject(placed) || !isWorldObjectVisibleByRotation('static', placed.id)) continue; const itemX = placed.x + .5; const itemY = placed.y + .5; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; @@ -715,11 +907,15 @@ } for (const runtime of dynamicRuntime) { const asset = findAsset(runtime.assetId); - if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue; + if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id] || isPermanentlyHiddenObject(runtime) || !isWorldObjectVisibleByRotation('dynamic', runtime.id)) 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 }); } + 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 }, preview: true }); + } items.sort(drawOrderCompare); return items; } @@ -795,8 +991,8 @@ return; } const tile = world.get(hoverTile.x, hoverTile.y); - const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length; - const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length; + 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}`; } @@ -807,8 +1003,8 @@ function inspectAt(x, y) { const tile = world.get(x, y); - const staticObjects = getPlacedAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); - const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); + 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; @@ -830,6 +1026,15 @@ } function placeSelected(x, y) { + if (placementPreview?.asset) { + const tile = world.get(x, y); + if (!canPlace(placementPreview.asset, tile)) return; + placementPreview.x = x; + placementPreview.y = y; + render(); + toast('Preview set. Use Place here or Back to canvas.'); + return; + } const asset = findAsset(selectedAssetId); if (!asset) { toast('Select an asset first.'); @@ -843,21 +1048,35 @@ const existingObject = asset.category === 'static' ? state.placed.find((p) => p.assetId === asset.id) : state.dynamicSummons.find((p) => p.assetId === asset.id); + 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(`World object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Remove objects before summoning more.`); + toast(`Local storage object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Lower the display cap or remove local objects before adding more.`); return; } + let object = null; if (asset.category === 'static') { const existing = state.placed.find((p) => p.assetId === asset.id); 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()); - toast(`${asset.name} moved.`); + toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { - const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 }; + const placed = { id: uid(), assetId: asset.id, 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()); toast(`${asset.name} placed.`); } @@ -865,23 +1084,28 @@ const existing = state.dynamicSummons.find((p) => p.assetId === asset.id); if (existing) { existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1; - toast(`${asset.name} moved.`); + 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, homeX: x, homeY: y, createdAt: Date.now(), version: 1 }; + const summon = { id: uid(), assetId: asset.id, 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(); const found = state.dynamicSummons.find((p) => p.assetId === asset.id); if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now()); } - const object = asset.category === 'static' - ? state.placed.find((p) => p.assetId === asset.id) - : state.dynamicSummons.find((p) => p.assetId === asset.id); rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object)); - spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); + recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(kind, object)); + if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); saveState(); + updateRotationStats(); } function canPlace(asset, tile) { @@ -974,7 +1198,7 @@ } function usesRightButtonAsPaint() { - return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; + return paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; } function onPaintPointerDown(event) { @@ -1141,6 +1365,7 @@ 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; @@ -1155,15 +1380,22 @@ editorPixels[index] = null; changed = true; } - const before = lightPixels.length; + const before = lightPixels.length + particlePixels.length; removeLightPixel(point.x, point.y); - changed = changed || before !== lightPixels.length; + removeParticlePixel(point.x, point.y); + changed = changed || before !== lightPixels.length + particlePixels.length; } 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); + changed = JSON.stringify(particlePixels) !== before; + updateSettingsSummary(); } else if (paintTool === 'depth') { const next = shiftKey ? 0 : (button === 2 ? -1 : depthPaintMode); if (depthPixels[index] !== next) { @@ -1215,6 +1447,7 @@ rightPixels: [...editorPixels], depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), + particlePixels: particlePixels.map((p) => ({ ...p })), doorPixel: { ...doorPixel }, editingSide, selectedColorCode, @@ -1228,6 +1461,7 @@ editorLeftPixels = mirrorPixels(editorPixels, editorSize); 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 })) : []; doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; @@ -1384,7 +1618,8 @@ baseSelection, basePixels: getDisplayEditorPixels(), baseDepthPixels: getDisplayDepthPixels(), - baseLightPixels: lightPixels.map((p) => ({ ...p })) + 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 }; @@ -1423,7 +1658,7 @@ if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return; selectionGesture.lastDx = dx; selectionGesture.lastDy = dy; - applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy); + applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseParticlePixels, selectionGesture.baseSelection, dx, dy); markEditorChanged(); drawEditor(); } @@ -1513,7 +1748,7 @@ return out; } - function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) { + function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, baseParticlePixels, selection, dx, dy) { const nextPixels = normalizePixels(basePixels, editorSize); const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize); const movedPixels = [...nextPixels]; @@ -1535,24 +1770,25 @@ } setDisplayEditorPixels(movedPixels); setDisplayDepthPixels(movedDepth); - lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy); + 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 moveDisplayLights(sourceLights, selection, dx, dy) { + function moveDisplayPoints(sourcePoints, selection, dx, dy) { const out = []; - for (const light of sourceLights || []) { - const shown = canonicalCellToDisplay(light.x, light.y); + for (const point of sourcePoints || []) { + const shown = canonicalCellToDisplay(point.x, point.y); if (!pointInSelection(shown.x, shown.y, selection)) { - out.push({ ...light }); + out.push({ ...point }); continue; } const nx = shown.x + dx; const ny = shown.y + dy; if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue; const canonical = displayCellToCanonical(nx, ny); - out.push({ ...light, x: canonical.x, y: canonical.y }); + out.push({ ...point, x: canonical.x, y: canonical.y }); } return out; } @@ -1562,7 +1798,7 @@ const bounded = clampSelectionDelta(editorSelection, dx, dy); if (!bounded.dx && !bounded.dy) return false; return commitEditorMutation(() => { - applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); + applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); return true; }); } @@ -1577,7 +1813,7 @@ const selection = { x: 0, y: 0, w: editorSize, h: editorSize }; const bounded = clampSelectionDelta(selection, dx, dy); if (!bounded.dx && !bounded.dy) return false; - applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); + 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, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) }; return true; @@ -1671,6 +1907,7 @@ editorPixels = mirrorPixels(editorPixels, editorSize); depthPixels = mirrorScalarPixels(depthPixels, editorSize); lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); + particlePixels = particlePixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x }; editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; return true; @@ -1682,6 +1919,7 @@ editorPixels = flipPixelsVertical(editorPixels, editorSize); depthPixels = flipScalarPixelsVertical(depthPixels, editorSize); lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); + particlePixels = particlePixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y }; editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; return true; @@ -1862,6 +2100,7 @@ depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size); els.assetSize.value = String(size); lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); + particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; resetEditorView(); drawEditor(); @@ -1934,6 +2173,17 @@ 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, .32); + 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; @@ -1970,6 +2220,17 @@ lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); } + function addParticlePixel(x, y, colorCode = selectedColorCode) { + const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode }; + const existing = particlePixels.find((p) => p.x === point.x && p.y === point.y); + if (existing) existing.c = colorCode; + else particlePixels.push(point); + } + + function removeParticlePixel(x, y) { + particlePixels = particlePixels.filter((p) => !(p.x === x && p.y === y)); + } + function alignEditorStateToBottom(size) { const shift = getBottomShift(editorPixels, size); @@ -1979,6 +2240,9 @@ lightPixels: lightPixels .map((p) => ({ ...p, y: p.y + shift })) .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), + particlePixels: particlePixels + .map((p) => ({ ...p, y: p.y + shift })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) } }; } @@ -2024,7 +2288,7 @@ const category = roleToCategory(role); const subtype = roleToSubtype(role); const size = editorSize; - const existing = editingAssetId ? findAsset(editingAssetId) : null; + const existing = null; const paintedDots = countPixels(editorPixels); if (paintedDots < 10) { toast('Draw at least 10 pixels before saving.'); @@ -2052,7 +2316,7 @@ right: encodePixels(aligned.rightPixels), left: 'mirror' } : null, - meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size) + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels) }; asset.contentHash = computeAssetContentHash(asset); if (existing) { @@ -2068,13 +2332,12 @@ editParentId = null; editOriginalId = null; editingAssetId = null; - editingAssetId = asset.id; saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); - els.lineageNote.textContent = existing ? 'Updated the original asset.' : 'Saved as a new asset.'; + els.lineageNote.textContent = 'Saved as a permanent library work. Island placement is a separate temporary exhibition object.'; return asset; } @@ -2084,7 +2347,61 @@ selectedAssetId = asset.id; setMode('place'); setDrawerOpen(false); - toast('Saved. Click an island tile to summon it.'); + toast('Saved to Library. Click an island tile to place it as a temporary exhibition object.'); + } + + + + 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(size); + const asset = { + id: `preview:${Date.now()}`, + name: (els.assetName.value || '').trim() || 'Preview work', + category, subtype, size, author: state.authorName || 'Local Artist', + pixels: encodePixels(aligned.rightPixels), + faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels), left: 'mirror' } : null, + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels) + }; + return asset; + } + + function checkCurrentEditorOnIsland() { + const asset = buildEditorAssetPreview(); + if (!asset) return; + placementPreview = { asset, x: null, y: null }; + setDrawerOpen(false); + setMode('place'); + if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; + toast('Click a valid tile to preview. Then choose Place here or Back to canvas.'); + } + + function cancelPlacementPreview() { + placementPreview = null; + if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; + setDrawerOpen(true); + render(); + } + + function confirmPreviewPlacement() { + if (!placementPreview?.asset || placementPreview.x == null || placementPreview.y == null) { + toast('Click a tile first.'); + return; + } + const asset = saveAssetFromEditor(); + if (!asset) return; + selectedAssetId = asset.id; + const { x, y } = placementPreview; + placementPreview = null; + if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; + placeSelected(x, y); } function newAsset() { @@ -2096,6 +2413,7 @@ dynamicKind = 'human'; editingSide = 'right'; lightPixels = []; + particlePixels = []; depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; setupEditor(8, blankPixels(8), null); @@ -2110,6 +2428,33 @@ if (els.authorName) els.authorName.value = state.authorName; } + function createLocalAccount() { + const name = (els.authorName?.value || state.authorName || '').trim(); + if (!name || name === 'Local Artist') { + toast('Choose a name first.'); + return; + } + state.authorName = name; + state.account = state.account || { id: `local:${uid()}`, createdAt: Date.now() }; + state.account.name = name; + saveState(); + updateAccountUI(); + toast('Local account created for prototype publishing.'); + } + + function updateAccountUI() { + if (!els.accountNote) return; + if (!state.account?.createdAt) { + els.accountNote.textContent = 'Account required to publish. This prototype only stores a local name and creation date. Works are permanent; island placements rotate.'; + els.createAccount.hidden = false; + return; + } + const quota = getPublishQuotaStatus(); + const day = getAccountAgeMs() < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+'; + els.accountNote.textContent = `Local account active (${day}). Publish quota: ${quota.used}/${quota.limit} this hour.`; + els.createAccount.hidden = true; + } + function focusAssetInWorld(asset) { const placed = state.placed.find((p) => p.assetId === asset.id); const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id); @@ -2131,7 +2476,7 @@ function renderLibrary() { els.assetList.innerHTML = ''; - const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]); + const visibleAssets = state.assets.filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id]); if (!visibleAssets.length) { els.assetList.textContent = 'No visible assets.'; renderHiddenAssets(); @@ -2145,6 +2490,7 @@ addLibrarySection('My works', mine); addLibrarySection('Others', others); renderHiddenAssets(); + renderLikedCodex(); } function addLibrarySection(title, assets) { @@ -2218,11 +2564,12 @@ down.classList.toggle('mutedVote', assetPreviousVote > 0); const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); hide.classList.toggle('mutedAction', assetPreviousVote >= 0); - const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => { + const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); - copyEdit(asset); + remixEdit(asset); }); - const move = makeButton('Place/Move', (event) => { + const move = isMine ? makeButton('Place/Move', (event) => { event?.stopPropagation?.(); selectedAssetId = asset.id; updateSelectedLabel(); @@ -2230,18 +2577,56 @@ setMode('place'); setDrawerOpen(false); toast('Click a valid tile to place or move it.'); - }); + }) : null; const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); - actions.append(up, down, hide, move, copy, del); + actions.append(up, down, hide, remix); + if (move) actions.append(move); + if (isMine) actions.append(del); meta.append(actions); card.append(preview, meta); return card; } + + function renderLikedCodex() { + if (!els.likedCodex) return; + 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); + } + } + const liked = [...likedIds].map(findAsset).filter(Boolean).slice(0, 12); + if (!liked.length) { + els.likedCodex.innerHTML = '
Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.
+Your works are permanent here. The island is a temporary exhibition: place your own new or past works; remix only copies lineage and pixels to the canvas.
+ @@ -166,13 +165,16 @@Saved to this browser only. Export JSON if you want to move or share a local world.
+Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.
Turn major visual systems on or off.
+Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.