diff --git a/README.md b/README.md index 1e0dc74..7336ed8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i ## Current prototype scope - Draw pixel assets in the in-browser editor. -- Save assets to the local library. +- Save assets to the local collection. - Place static assets and summon dynamic assets onto the island. - Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only. - Local voting, hiding, remix/copy edit, and import/export tools are included. @@ -161,12 +161,12 @@ This build does not call a server from the browser. The island display cap is ap 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. +- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Collection. - 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. +- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Collection. - 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. @@ -187,3 +187,85 @@ 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. + +## Phase 5 Exhibition UI polish follow-up +- Compressed the account/name card so the top HUD uses less vertical space. +- Removed the right-side Pan / Place / Erase HUD; placement is guided through Save + Place and Library actions. +- Renamed the liked codex label to Favorite. +- Removed the Advanced Draw depth Clear button; depth can still be cleared with Shift/right-click. +- Normal Erase now removes Light / Particle / Depth metadata on the first click and removes the pixel color on the next click. +- Light rendering and nearby glow are night-only, with subtle random flicker. +- Remix previews hide the nearby source work while checking placement, and final placement nudges away from the remix source to avoid doubled sprites. +- Depth edge shading now reacts to configured light positions or the current sun/moon direction; object silhouette edges are excluded. + +## v8 polish notes + +- `Save + Place on Island` now auto-generates a local account when needed. The generated account stores an ID, editable display name, and generated password in local storage; users are prompted to change the password. +- `Check on Island` and `New` were removed from the finish area. Saved works enter an island placement preview with `Place here` and `Back to canvas`. +- Editor Select now clears with right-click. The island also shows the left-click/right-click selection hint. +- Basic draw buttons keep a stable layout even when Advanced is open; advanced tools are placed below them. +- Draw tools now have distinct colors and compact icons. +- Particle editing no longer paints emitter positions. Particle effects emit from the whole sprite, with palette-based color and up/down/left/right direction. + + +## v9 interaction polish and bug fixes + +- Removed automatic remix-source avoidance: remix children can now be placed near their source without being forced to a different tile. +- Animated coastal foam with drift, bob, scale, and opacity pulses. +- Removed Collection/Create buttons from the Pixel Island title bar and added a compact cute menu button for the left drawer. +- Added a confirmation dialog before clearing the entire canvas. +- Palette swatches now handle pointerdown directly so the first palette click is applied reliably. +- Particle right-click no longer disables the particle effect; right-click pans/does nothing, while Shift-click can disable. +- Added particle range controls: select an area, then press “Use Sel”; “Whole” resets emission to the whole sprite. +- Swapped Particle and Depth button order. +- Left/Right canvas side buttons no longer mirror the editing canvas view. +- Fixed dynamic animal placement preview so the sprite is visible before pressing Place here. + +### v10 interaction / lighting polish + +- Replaced the title-side menu control with a larger bottom `DRAW` button. +- Warmed the evening phase with orange/vermilion sky tint and warm darkness overlay. +- Increased lamp reflection bleed radius and light radius so nearby sprites glow more softly. +- Dynamic sprites now save a canvas marked `Left` by mirroring it into the canonical right-facing asset; runtime facing is driven by the actual movement vector. +- Hidden particle range overlays no longer look like an undeletable blue canvas square; the range overlay is shown only while the Particle tool is active. +- Remix actions are now displayed as a bottom sheet. Report is inside a small Menu section. +- Added teleport-to-remix-source from the selected object sheet. +- Object draw order now uses the rendered bottom/contact line, so lower objects consistently draw in front. +- 8×8 sprites use their whole sprite bounding box for click selection, including transparent cells. +- Coastal foam motion was made more visible. + + +## v12 lighting/compression notes + +- Night light reflections are now rendered per sprite immediately after the sprite, preserving front/back draw order. +- Reflection tint affects both depth and non-depth pixels; depth pixels receive stronger directional response. +- Depth no longer uses light-source response unless the night lighting system is actually active. +- Strong compression direction for weak servers: store indexed palette pixels as bit-packed palette indices plus an alpha bitmask and RLE/LZ on top; avoid PNG/base64 as the canonical server payload. + +## v13 compression and remix lineage changes + +- Palette index encoding is the canonical pixel format. Each pixel stores `.` for transparent or a one-character palette code. +- Compact export now supports `bp6`, a 6-bit packed plane format. Transparent plus the 62 palette codes fit in one 6-bit value. +- Compact export chooses the smallest representation per pixel plane: cropped raw, cropped RLE, or cropped 6-bit packing. +- Remix delta storage was removed again; remixes are stored as independent packed pixel planes. +- Deleting a source asset now cascades through remix descendants. Their island placements, dynamic summons, votes, hidden flags, and moderation rows are removed together. +- Sync events now include `asset.delete`; applying that event also cascades to remix descendants on the receiver. + + +## v14 rollback / interaction polish +- Removed remix delta saving and remix-source cascade deletion. +- Particles are cell-based again, like Light and Depth: choose color + direction, then paint emitter cells. +- Selected dynamic sprites bounce too, with a slight deterministic tilt. +- The object speech bubble is kept above the object and away from the DRAW dock. +- Added a compact clock in the upper-right corner. +- Reworked daylight/moonlight shadow transitions so evening fades sun shadows out before night, moon shadows fade in during night, and pre-dawn moon shadows disappear before sunrise. + +## v14b startup fix +- Restored `updateSelectedLabel()` so bootstrap no longer throws `ReferenceError`. + + +## v15 interaction polish +- Restored object speech bubbles above the actual rendered sprite, using sprite draw coordinates instead of tile-center estimates. +- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers. +- Kept selected-object shadows mirrored from the ground line while the sprite jumps. +- Smoothed sky and overlay interpolation across dawn, day, evening, and night. diff --git a/app.js b/app.js index e42deda..82ebed8 100644 --- a/app.js +++ b/app.js @@ -3,8 +3,8 @@ console.info('Pixel Island Summoner loaded'); - const STORAGE_KEY = 'pixel-island-summoner:phase5d'; - const SAVE_SCHEMA = 10; + const STORAGE_KEY = 'pixel-island-summoner:phase5e'; + const SAVE_SCHEMA = 13; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -89,18 +89,23 @@ const els = { canvas: $('worldCanvas'), openEditor: $('openEditor'), + openCreate: $('openCreate'), + openCollection: $('openCollection'), + openMenu: $('openMenu'), closeEditor: $('closeEditor'), - placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), createAccount: $('createAccount'), accountNote: $('accountNote'), + placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), 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'), bubbleEdit: $('bubbleEdit'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), + 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'), @@ -116,7 +121,7 @@ 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'), 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'), + 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'), 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'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'), @@ -167,6 +172,7 @@ 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; @@ -229,7 +235,9 @@ render(); }); - els.openEditor.addEventListener('click', () => setDrawerOpen(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'); clearWorldSelection(false); setDrawerOpen(true); }); els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); els.tabs.forEach((button) => { @@ -237,19 +245,28 @@ }); els.authorName?.addEventListener('input', () => { - state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; + 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.createAccount?.addEventListener('click', () => createLocalAccount()); + els.accountPass?.addEventListener('input', () => { + if (!state.account) return; + state.account.password = (els.accountPass.value || '').trim(); + saveState(); + updateAccountUI(); + }); + + els.createAccount?.addEventListener('click', () => createLocalAccount(false)); els.confirmPreviewPlace?.addEventListener('click', confirmPreviewPlacement); els.backToCanvas?.addEventListener('click', cancelPlacementPreview); els.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; + spriteCache.clear(); saveState(); render(); }); @@ -288,7 +305,12 @@ els.canvas.addEventListener('pointerup', onWorldPointerUp); els.canvas.addEventListener('pointercancel', onWorldPointerUp); els.canvas.addEventListener('wheel', onWorldWheel, { passive: false }); - els.canvas.addEventListener('contextmenu', (event) => event.preventDefault()); + els.canvas.addEventListener('contextmenu', (event) => { + event.preventDefault(); + cancelPlacementPreview(false); + clearWorldSelection(true); + setMode('inspect'); + }); document.addEventListener('contextmenu', onDocumentContextMenu); document.addEventListener('visibilitychange', onVisibilityChange); @@ -337,29 +359,43 @@ 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(); + drawEditor(); + }); + els.particleUseSelection?.addEventListener('click', setParticleRangeFromSelection); + els.particleClearRange?.addEventListener('click', clearParticleRange); els.depthHigh?.addEventListener('click', () => setDepthPaintMode(1)); els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1)); - els.depthClear?.addEventListener('click', () => setDepthPaintMode(0)); els.toggleAdvanced?.addEventListener('click', () => { advancedDraw = !advancedDraw; + 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.depthClear, !advancedDraw); + toggleHidden(els.particleDirectionWrap, !advancedDraw); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced.classList.toggle('active', advancedDraw); + els.drawer?.classList.toggle('advancedTools', advancedDraw); if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush'); + updateParticleUI(); drawEditor(); }); els.clearPaint.addEventListener('click', () => { + if (!confirm('Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.')) return; commitEditorMutation(() => { editorPixels = blankPixels(editorSize); editorLeftPixels = mirrorPixels(editorPixels, editorSize); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; particlePixels = []; + particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; editorSelection = null; return true; }); @@ -367,7 +403,7 @@ els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); els.flipVertical?.addEventListener('click', flipEditorVertical); els.outlinePaint?.addEventListener('click', applyEditorOutline); - els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); }); + 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)); @@ -381,7 +417,9 @@ els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); - els.bubbleReport?.addEventListener('click', () => openReportDialog()); + 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(); }); @@ -402,7 +440,7 @@ els.saveAsset.addEventListener('click', saveAssetFromEditor); els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); els.checkOnIsland?.addEventListener('click', checkCurrentEditorOnIsland); - els.newAsset.addEventListener('click', newAsset); + els.newAsset?.addEventListener('click', newAsset); els.exportData.addEventListener('click', exportData); els.importData.addEventListener('click', importData); els.exportCompact?.addEventListener('click', exportCompactData); @@ -448,7 +486,7 @@ function onDocumentContextMenu(event) { if (!els.drawer?.classList.contains('open')) return; - if (els.drawer.contains(event.target) || els.openEditor.contains(event.target)) 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); } @@ -460,6 +498,7 @@ function setMode(nextMode) { mode = nextMode; + if (mode === 'place' || mode === 'erase') clearWorldSelection(false); const isInspect = mode === 'inspect'; const isPlace = mode === 'place'; const isErase = mode === 'erase'; @@ -471,6 +510,7 @@ } function refreshCategoryUI() { + els.drawer?.classList.toggle('advancedTools', advancedDraw); const role = currentRole(); const isStatic = roleToCategory(role) === 'static'; if (isStatic) staticKind = roleToSubtype(role); @@ -481,9 +521,11 @@ toggleHidden(els.sideSwitcher, isStatic); 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.particleDirectionWrap, !advancedDraw); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); @@ -496,6 +538,7 @@ updateSideButtons(); clampEditorView(); drawEditor(); + updateParticleUI(); updateSettingsSummary(); } @@ -503,8 +546,8 @@ if (!els.roleHint) return; const role = currentRole(); const textMap = { - human: 'Humans need ▶ Right and ◀ Left sprites. They hop often and visit buildings.', - animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.', + 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.', nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.', building: 'Buildings attract humans. Use Door to mark the entrance.', ship: 'Ships are static water objects. They float and emit ring ripples.', @@ -519,14 +562,13 @@ 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 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)} / 2× static pixels / ${lightText} / ${particleText}${doorText}.`; } else { - 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}.`; + 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)} / ${sideText}${particleText}.`; } } @@ -536,35 +578,41 @@ ({ 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.', + 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: 'Drag a rectangle to select pixels, then drag or nudge the selection.', + 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: 'Paint particle emitter cells with the selected palette color. Hold Shift or right-click to erase emitters.', + 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/Clear buttons; Shift clears.' + 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, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); - if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); + [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'); + updateParticleUI(); } function setDepthPaintMode(mode) { - depthPaintMode = mode === -1 ? -1 : mode === 0 ? 0 : 1; - [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); - ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); + 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' : depthPaintMode < 0 ? 'low' : 'clear'; - if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Left-drag paints this depth value.`; + 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; + 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(); } @@ -577,7 +625,9 @@ function onWorldPointerDown(event) { event.preventDefault(); if (event.button === 2) { - clearWorldSelection(false); + cancelPlacementPreview(false); + clearWorldSelection(true); + setMode('inspect'); pointer.down = false; pointer.id = null; return; @@ -870,6 +920,7 @@ } 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.'); @@ -894,12 +945,18 @@ } + + function shouldHideUnderPlacementPreview(assetId, x, y) { + return false; + } + 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 || 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; @@ -908,6 +965,7 @@ for (const runtime of dynamicRuntime) { 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 }); @@ -920,7 +978,17 @@ 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 || '')); } @@ -977,6 +1045,9 @@ localY = worldY - info.drawY; } if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false; + // Tiny 8×8 works are hard to click. Use the whole sprite box for them, + // including transparent cells, while larger works still use opaque pixels. + if ((asset.size || 0) <= 8) return true; const px = Math.floor(localX / scale); const py = Math.floor(localY / scale); if (px < 0 || py < 0 || px >= asset.size || py >= asset.size) return false; @@ -997,8 +1068,21 @@ } 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 ? `${asset.name || 'Untitled'} · ${cap(subtypeToRole(asset))}` : 'No collection work selected'; + } } function inspectAt(x, y) { @@ -1019,20 +1103,45 @@ function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) { selectedObject = { kind, id: objectId, assetId, selectedAt }; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; selectedAssetId = assetId; updateSelectedLabel(); renderLibrary(); 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) { 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; placementPreview.x = x; placementPreview.y = y; render(); - toast('Preview set. Use Place here or Back to canvas.'); + toast(adjusted.moved ? 'Preview nudged away from the remix source. Use Place here or Back to canvas.' : 'Preview set. Use Place here or Back to canvas.'); return; } const asset = findAsset(selectedAssetId); @@ -1042,8 +1151,11 @@ setTab('library'); return; } - const tile = world.get(x, y); + 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.'); const existingObject = asset.category === 'static' ? state.placed.find((p) => p.assetId === asset.id) @@ -1198,11 +1310,15 @@ } function usesRightButtonAsPaint() { - return paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; + 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()); @@ -1289,6 +1405,10 @@ // 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 }; @@ -1357,9 +1477,7 @@ ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor) : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); if (nextDisplay.changed) { - editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(nextDisplay.pixels, editorSize) - : nextDisplay.pixels; + editorPixels = nextDisplay.pixels; editorLeftPixels = mirrorPixels(editorPixels, editorSize); if (fillColor === null && Array.isArray(nextDisplay.cells)) { for (const cell of nextDisplay.cells) { @@ -1376,14 +1494,18 @@ changed = true; } } else if (paintTool === 'erase') { - if (editorPixels[index] !== null) { + 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; } - const before = lightPixels.length + particlePixels.length; - removeLightPixel(point.x, point.y); - 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); @@ -1393,11 +1515,13 @@ } 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); + 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 ? 0 : (button === 2 ? -1 : depthPaintMode); + const next = (shiftKey || button === 2) ? 0 : depthPaintMode; if (depthPixels[index] !== next) { depthPixels[index] = next; changed = true; @@ -1448,6 +1572,7 @@ depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), particlePixels: particlePixels.map((p) => ({ ...p })), + particleConfig: { ...particleConfig }, doorPixel: { ...doorPixel }, editingSide, selectedColorCode, @@ -1462,6 +1587,7 @@ 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(editorSize / 2), y: editorSize - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; @@ -1557,9 +1683,7 @@ const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key]; moveSelectionBy(delta[0], delta[1]); } else if (key === 'escape') { - editorSelection = null; - drawEditor(); - updateEditorSelectionButtons(); + 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]); } @@ -1718,23 +1842,16 @@ } function getDisplayDepthPixels() { - const source = normalizeDepthPixels(depthPixels, editorSize); - return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorScalarPixels(source, editorSize) - : source; + return normalizeDepthPixels(depthPixels, editorSize); } function setDisplayEditorPixels(pixels) { - editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(pixels, editorSize) - : normalizePixels(pixels, editorSize); + editorPixels = normalizePixels(pixels, editorSize); editorLeftPixels = mirrorPixels(editorPixels, editorSize); } function setDisplayDepthPixels(pixels) { - depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorScalarPixels(pixels, editorSize) - : normalizeDepthPixels(pixels, editorSize); + depthPixels = normalizeDepthPixels(pixels, editorSize); } function mirrorScalarPixels(values, size) { @@ -1902,6 +2019,85 @@ 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] || '↑'); + } + + function particleRangeLabel() { + return `${particlePixels.length} emitter cell${particlePixels.length === 1 ? '' : 's'}`; + } + + function updateParticleUI() { + particleConfig = normalizeParticleConfig(particleConfig); + if (els.particleDirection) els.particleDirection.value = particleConfig.dir; + 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 = mirrorPixels(editorPixels, editorSize); @@ -2066,7 +2262,10 @@ 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() { @@ -2074,18 +2273,14 @@ } function getDisplayEditorPixels() { - return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(editorPixels, editorSize) - : normalizePixels(editorPixels, editorSize); + return normalizePixels(editorPixels, editorSize); } function displayCellToCanonical(x, y) { - if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; return { x, y }; } function canonicalCellToDisplay(x, y) { - if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; return { x, y }; } @@ -2101,6 +2296,7 @@ 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); + particleConfig = normalizeParticleConfig(particleConfig); doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; resetEditorView(); drawEditor(); @@ -2178,7 +2374,7 @@ const lx = (shown.x + .5) * cell; const ly = (shown.y + .5) * cell; const color = colorToHex(emitter.c || selectedColorCode); - pctx.fillStyle = hexToRgba(color, .32); + 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; @@ -2220,10 +2416,10 @@ 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 }; + function addParticlePixel(x, y, colorCode = selectedColorCode, dir = particleConfig.dir || 'up') { + const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 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; + if (existing) { existing.c = colorCode; existing.dir = point.dir; } else particlePixels.push(point); } @@ -2232,18 +2428,50 @@ } + function mirrorLightPointsHorizontal(points, size) { + return (Array.isArray(points) ? points : []) + .map((p) => ({ ...p, x: size - 1 - clampInt(p.x, 0, size - 1, 0) })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size); + } + + function mirrorParticlePointsHorizontal(points, size) { + return (Array.isArray(points) ? points : []) + .map((p) => ({ ...p, x: size - 1 - clampInt(p.x, 0, size - 1, 0), dir: p.dir === 'left' ? 'right' : p.dir === 'right' ? 'left' : (p.dir || 'up') })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size); + } + + function shiftParticlePointsVertical(points, size, shift = 0) { + return (Array.isArray(points) ? points : []) + .map((p) => ({ ...p, y: clampInt(p.y, 0, size - 1, 0) + shift })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size); + } + + + function normalizeEditorOrientationForSave(size) { + const dynamicLeftCanvas = roleToCategory(currentRole()) === 'dynamic' && editingSide === 'left'; + const rightPixels = dynamicLeftCanvas ? mirrorPixels(editorPixels, size) : normalizePixels(editorPixels, size); + const normalizedDepth = normalizeDepthPixels(depthPixels, size); + const orientedDepth = dynamicLeftCanvas ? mirrorDepthPixels(normalizedDepth, size) : normalizedDepth; + const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal(lightPixels, size) : lightPixels.map((p) => ({ ...p })); + const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal(particlePixels, size) : particlePixels.map((p) => ({ ...p })); + const orientedParticleConfig = normalizeParticleConfig({ ...particleConfig, enabled: orientedParticles.length > 0 }, size); + return { rightPixels, depthPixels: orientedDepth, lightPixels: orientedLights, particlePixels: orientedParticles, particleConfig: orientedParticleConfig, mirroredFromLeft: dynamicLeftCanvas }; + } + + function alignEditorStateToBottom(size) { - const shift = getBottomShift(editorPixels, size); + const oriented = normalizeEditorOrientationForSave(size); + const shift = getBottomShift(oriented.rightPixels, size); return { - rightPixels: shiftPixelsVertical(editorPixels, size, shift), - depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift), - lightPixels: lightPixels + rightPixels: shiftPixelsVertical(oriented.rightPixels, size, shift), + depthPixels: shiftDepthPixelsVertical(oriented.depthPixels, size, shift), + lightPixels: oriented.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) } + particlePixels: shiftParticlePointsVertical(oriented.particlePixels, size, shift), + particleConfig: normalizeParticleConfig({ ...oriented.particleConfig, enabled: oriented.particlePixels.length > 0 }, size), + door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) }, + mirroredFromLeft: oriented.mirroredFromLeft }; } @@ -2325,7 +2553,7 @@ toast(`${asset.name} updated.`); } else { state.assets.unshift(asset); - toast(`${asset.name} saved.`); + toast(aligned.mirroredFromLeft ? `${asset.name} saved. Left-facing canvas was mirrored into right-facing movement.` : `${asset.name} saved.`); } selectedAssetId = asset.id; recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); @@ -2337,17 +2565,22 @@ hydrateRuntime(); renderLibrary(); updateSelectedLabel(); - els.lineageNote.textContent = 'Saved as a permanent library work. Island placement is a separate temporary exhibition object.'; + 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 = { asset, x: null, y: null, savedAssetId: asset.id }; + clearWorldSelection(false); setMode('place'); setDrawerOpen(false); - toast('Saved to Library. Click an island tile to place it as a temporary exhibition object.'); + if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; + if (els.openEditor) els.openEditor.hidden = true; + toast('Saved to Collection. Click a tile, then choose Place here or Back to canvas.'); } @@ -2380,14 +2613,17 @@ setDrawerOpen(false); setMode('place'); if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; + if (els.openEditor) els.openEditor.hidden = true; toast('Click a valid tile to preview. Then choose Place here or Back to canvas.'); } - function cancelPlacementPreview() { + function cancelPlacementPreview(reopenDrawer = true) { + const hadPreview = !!placementPreview; placementPreview = null; if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; - setDrawerOpen(true); - render(); + if (!reopenDrawer && els.openEditor) els.openEditor.hidden = false; + if (reopenDrawer) setDrawerOpen(true); + if (hadPreview) render(); } function confirmPreviewPlacement() { @@ -2395,12 +2631,16 @@ toast('Click a tile first.'); return; } - const asset = saveAssetFromEditor(); - if (!asset) return; - selectedAssetId = asset.id; + const assetId = placementPreview.savedAssetId || placementPreview.asset.id; + if (!findAsset(assetId)) { + toast('Saved asset is missing. Back to canvas and save again.'); + return; + } + selectedAssetId = assetId; const { x, y } = placementPreview; placementPreview = null; if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; + if (els.openEditor) els.openEditor.hidden = false; placeSelected(x, y); } @@ -2414,6 +2654,7 @@ editingSide = '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); @@ -2424,35 +2665,82 @@ function hydrateAuthorUI() { - state.authorName = state.authorName || 'Local Artist'; + state.account = normalizeAccount(state.account); + state.authorName = state.account?.name || state.authorName || 'Local Artist'; if (els.authorName) els.authorName.value = state.authorName; + updateAccountUI(); } - function createLocalAccount() { - const name = (els.authorName?.value || state.authorName || '').trim(); - if (!name || name === 'Local Artist') { - toast('Choose a name first.'); - return; + 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; } - 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.'); + 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) { - 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; + 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 = 'Save + Place creates a local ID, name, and password. Change the password after creation.'; + if (els.createAccount) els.createAccount.hidden = false; 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 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; + els.accountNote.textContent = `Account ${day}; publish ${quota.used}/${quota.limit} this hour. Change the password from the generated default.`; + if (els.createAccount) els.createAccount.hidden = true; } function focusAssetInWorld(asset) { @@ -2525,7 +2813,7 @@ const filters = ['all', 'human', 'animal', 'nature', 'building', 'ship', 'other']; const index = filters.indexOf(libraryFilter); libraryFilter = filters[(index + 1) % filters.length]; - toast(`Library filter: ${libraryFilterLabel()}`); + toast(`Collection filter: ${libraryFilterLabel()}`); } function makeAssetCard(asset) { @@ -2607,10 +2895,10 @@ } const liked = [...likedIds].map(findAsset).filter(Boolean).slice(0, 12); if (!liked.length) { - els.likedCodex.innerHTML = '
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.
@@ -162,10 +179,10 @@Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.
+Works are durable collection works; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.