From a05c03107401e9f7538713f91d6c8d13d93d80b2 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 16:26:44 +0900 Subject: [PATCH] update --- README.md | 88 +- app.js | 1245 +++++++++++++---- index.html | 135 +- js/phase2-sync.js | 256 +++- .../compression_lab.cpython-313.pyc | Bin 2591 -> 2515 bytes .../rotation_worker.cpython-313.pyc | Bin 20739 -> 20663 bytes styles.css | 444 ++++++ 7 files changed, 1782 insertions(+), 386 deletions(-) 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 = '
Liked codex
Works you upvote will appear here.
'; + els.likedCodex.innerHTML = '
Favorite
Works you upvote appear here.
'; return; } - els.likedCodex.innerHTML = '
Liked codex
'; + els.likedCodex.innerHTML = '
Favorite
'; const list = els.likedCodex.querySelector('.likedCodexList'); for (const asset of liked) { const item = document.createElement('button'); @@ -2648,7 +2936,7 @@ if (hiddenAssets.length) { const title = document.createElement('div'); title.className = 'hiddenSectionTitle'; - title.textContent = 'Hidden library assets'; + title.textContent = 'Hidden collection works'; els.hiddenAssetList.append(title); for (const asset of hiddenAssets) els.hiddenAssetList.append(makeHiddenAssetCard(asset)); } @@ -2674,9 +2962,9 @@ function hiddenReasonLabel(object) { if (!object) return 'Hidden locally'; - if (object.status === 'hidden_rotation') return 'Not shown on island: exhibition is full. Works remain in your Library.'; + if (object.status === 'hidden_rotation') return 'Not shown on island: exhibition is full. Works remain in your Collection.'; if (object.status === 'permanent_hidden' || object.status === 'violation_hidden') return 'Hidden by moderation. This work is not visible to authors or viewers.'; - if (object.hiddenReason === 'rotation_cap' || object.hiddenReason === 'extreme_downvotes') return 'Not shown on island: exhibition is full. Works remain in your Library.'; + if (object.hiddenReason === 'rotation_cap' || object.hiddenReason === 'extreme_downvotes') return 'Not shown on island: exhibition is full. Works remain in your Collection.'; return 'Hidden locally'; } @@ -2779,10 +3067,14 @@ swatch.dataset.code = entry.code; swatch.title = `${entry.code} ${entry.color}`; swatch.style.background = entry.color; - swatch.addEventListener('click', () => { + const choose = (event) => { + event.preventDefault(); + event.stopPropagation(); selectPaletteCode(entry.code); drawEditor(); - }); + }; + swatch.addEventListener('pointerdown', choose); + swatch.addEventListener('click', (event) => event.preventDefault()); els.paletteGrid.append(swatch); } els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b'; @@ -2803,34 +3095,29 @@ function loadAssetIntoEditor(asset, mode = 'remix') { setDrawerOpen(true); setTab('draw'); - const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); - const editExisting = mode === 'edit' && mine; - editingAssetId = editExisting ? asset.id : null; - editParentId = editExisting ? asset.parentAssetId : asset.id; - editOriginalId = editExisting ? asset.originalAssetId : (asset.originalAssetId || asset.id); - els.assetName.value = editExisting ? asset.name : `${asset.name} Remix`; + const editExisting = false; + editingAssetId = null; + editParentId = asset.id; + editOriginalId = asset.originalAssetId || asset.id; + els.assetName.value = `${asset.name} Remix`; els.assetCategory.value = subtypeToRole(asset); staticKind = asset.category === 'static' ? asset.subtype : staticKind; dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; editingSide = 'right'; const right = getAssetPixels(asset, 'right'); lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); - particlePixels = (asset.meta?.particlePixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || selectedColorCode })); + particlePixels = (asset.meta?.particlePixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || selectedColorCode, dir: p.dir || 'up' })); + particleConfig = normalizeParticleConfig((particlePixels.length ? { enabled: true, c: particlePixels[0].c || selectedColorCode, dir: particlePixels[0].dir || 'up' } : { enabled: false, c: selectedColorCode, dir: 'up' }), asset.size); depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size); doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 }; setupEditor(asset.size, right, null, depthPixels); clearEditorHistory(); refreshCategoryUI(); - els.lineageNote.textContent = editExisting - ? `Editing original “${asset.name}”. Save updates this asset.` - : `Remixing “${asset.name}”. Save creates a separate new asset.`; + els.lineageNote.textContent = `Remixing “${asset.name}”. Save creates a separate new collection work.`; } function editOriginalAsset(asset) { remixEdit(asset); - return; - - loadAssetIntoEditor(asset, 'edit'); } function remixEdit(asset) { @@ -2843,23 +3130,38 @@ function deleteAsset(asset) { + if (!asset) return; const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); - if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return; + const usageText = used ? ' Island placements that use it will also be removed.' : ''; + if (!confirm(`Delete “${asset.name}”?${usageText}`)) return; + + const removedObjectIds = new Set(); + for (const placed of state.placed || []) if (placed.assetId === asset.id) removedObjectIds.add(placed.id); + for (const summon of state.dynamicSummons || []) if (summon.assetId === asset.id) removedObjectIds.add(summon.id); + state.assets = state.assets.filter((a) => a.id !== asset.id); state.placed = state.placed.filter((p) => p.assetId !== asset.id); state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id); + + delete state.assetVotes?.[asset.id]; + delete state.hiddenAssets?.[asset.id]; + for (const id of removedObjectIds) { + delete state.objectVotes?.[id]; + delete state.hiddenObjects?.[id]; + } + state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); + if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null; + if (selectedObject && (selectedObject.assetId === asset.id || removedObjectIds.has(selectedObject.id))) selectedObject = null; + + recordSyncEvent(Phase2Sync?.createAssetDeleteEvent?.(asset.id)); saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); - } - - function updateSelectedLabel() { - if (!els.selectedAssetName) return; - const asset = findAsset(selectedAssetId); - els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none'; + updateSelectionBubble(performance.now()); + toast(`${asset.name} deleted.`); } function hydrateRuntime() { @@ -2879,6 +3181,7 @@ targetX: spawn.x + .5, targetY: spawn.y + .5, vx: 0, + lastMoveX: 0, facing: 1, idleUntil: performance.now() + 700 + Math.random() * 1600, hiddenUntil: 0, @@ -2927,12 +3230,14 @@ } if (time < (item.idleUntil || 0)) { item.vx = 0; + item.lastMoveX = 0; continue; } const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); if (time < (item.idleUntil || 0)) { item.vx = 0; + item.lastMoveX = 0; continue; } @@ -2941,10 +3246,13 @@ const len = Math.hypot(dx, dy) || 1; const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; const step = Math.min(speed, len); - if (Math.abs(dx) > 0.02) item.facing = Math.sign(dx); - item.vx = Math.abs(dx) > 0.02 ? Math.sign(dx) : 0; - item.x += (dx / len) * step; - item.y += (dy / len) * step; + const moveX = (dx / len) * step; + const moveY = (dy / len) * step; + if (Math.abs(moveX) > 0.003) item.facing = Math.sign(moveX); + item.vx = Math.abs(moveX) > 0.003 ? Math.sign(moveX) : 0; + item.lastMoveX = moveX; + item.x += moveX; + item.y += moveY; if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) { const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1)); @@ -2967,6 +3275,7 @@ item.targetX = item.x; item.targetY = item.y; item.vx = 0; + item.lastMoveX = 0; item.nextDecisionAt = item.idleUntil + 400 + Math.random() * 1200; return; } @@ -3055,46 +3364,107 @@ function updateClock() { const phase = getPhase(); + const progress = phase.progress || 0; + const degrees = progress * 360; + if (els.analogClockHand) els.analogClockHand.style.transform = `translate(-50%, -100%) rotate(${degrees.toFixed(2)}deg)`; + if (els.analogClock) { + els.analogClock.style.setProperty('--clock-rotate', `${degrees.toFixed(2)}deg`); + els.analogClock.setAttribute('aria-label', `${phase.label} island clock`); + els.analogClock.title = `${phase.label} · 10 min = 1 island day`; + } const second = Math.floor(Date.now() / 1000); - if (second === lastClockSecond) return; - lastClockSecond = second; - els.phaseLabel.textContent = phase.label; - els.phaseBar.style.width = `${Math.round(phase.progress * 100)}%`; + if (second !== lastClockSecond) { + lastClockSecond = second; + if (els.phaseLabel) els.phaseLabel.textContent = phase.label; + } + if (els.phaseBar) els.phaseBar.style.width = `${Math.round(progress * 100)}%`; } function getShadowForMinute(minute) { - const isNight = minute >= 6; - const local = isNight ? (minute - 6) / 4 : minute / 6; - const eased = clamp(local, 0, 1); - const elevation = Math.sin(eased * Math.PI); - // Morning: down-left, Noon: straight down, Evening: down-right. - // The top edge of the shadow is always anchored to the sprite's bottom edge. - const dirX = lerp(-1.0, 1.0, eased); - const length = lerp(1.35, 0.58, elevation); + const m = clamp(Number(minute) || 0, 0, 10); + const smooth = (t) => clamp(t, 0, 1) * clamp(t, 0, 1) * (3 - 2 * clamp(t, 0, 1)); + let dirX = -0.9; + let length = 1.2; + let alpha = 0; + let scaleY = 0.26; + + if (m < 0.65) { + // Pre-dawn: moon shadow fades out completely before morning begins. + const t = smooth(m / 0.65); + dirX = lerp(0.42, 0.18, t); + length = lerp(1.05, 0.92, t); + alpha = lerp(0.12, 0.0, t); + scaleY = lerp(0.22, 0.18, t); + } else if (m < 1.35) { + // Morning: sunlight shadow appears from zero. + const t = smooth((m - 0.65) / 0.70); + dirX = lerp(-1.05, -0.82, t); + length = lerp(1.38, 1.12, t); + alpha = lerp(0.0, 0.27, t); + scaleY = lerp(0.22, 0.27, t); + } else if (m < 5.65) { + // Day: rotate gradually with the sun, no hard switch in the evening. + const t = smooth((m - 1.35) / 4.30); + const elevation = Math.sin(t * Math.PI); + dirX = lerp(-0.82, 0.98, t); + length = lerp(1.12, 1.34, t) - elevation * 0.48; + alpha = lerp(0.27, 0.20, elevation); + scaleY = 0.24 + 0.06 * (1 - elevation); + } else if (m < 6.40) { + // Evening: keep the same sun-side direction and fade to zero before night. + const t = smooth((m - 5.65) / 0.75); + dirX = lerp(0.98, 1.08, t); + length = lerp(1.34, 1.56, t); + alpha = lerp(0.20, 0.0, t); + scaleY = lerp(0.28, 0.20, t); + } else if (m < 7.25) { + // Night starts after the sunlight shadow disappears; moon shadow fades in. + const t = smooth((m - 6.40) / 0.85); + dirX = lerp(0.20, -0.36, t); + length = lerp(0.92, 1.08, t); + alpha = lerp(0.0, 0.12, t); + scaleY = lerp(0.18, 0.23, t); + } else if (m < 9.20) { + const t = smooth((m - 7.25) / 1.95); + dirX = lerp(-0.36, -0.58, t); + length = lerp(1.08, 1.18, t); + alpha = 0.12; + scaleY = 0.23; + } else { + // Before dawn: moon shadow darkens/fades out to zero, then morning creates a new sun shadow. + const t = smooth((m - 9.20) / 0.80); + dirX = lerp(-0.58, 0.42, t); + length = lerp(1.18, 1.05, t); + alpha = lerp(0.12, 0.0, t); + scaleY = lerp(0.23, 0.18, t); + } + return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), - scaleY: 0.24 + 0.06 * length, - alpha: isNight ? .12 : lerp(.28, .16, elevation) + scaleY, + alpha }; } function getPhase() { if (visualSettings().enableDayNight === false) { - return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; + return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; } const t = mod(Date.now(), DAY_MS); const minute = t / 60000; const stops = [ - // Fast dawn: sky shifts quickly, then stays stable through the day. - { at: 0.00, key: 'morning', label: 'Morning', sky: '#f6b06f', darkness: 0.22, tint: [255, 204, 132, 0.16] }, - { at: 0.55, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 5.00, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - // Fast dusk: the main sky movement happens here. - { at: 5.55, key: 'evening', label: 'Evening', sky: '#f09076', darkness: 0.22, tint: [255, 128, 96, 0.15] }, - { at: 6.10, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] }, - { at: 10.00, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] } + { at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }, + { at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] }, + { at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] }, + { at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, + { at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, + { at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] }, + { at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] }, + { at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] }, + { at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] }, + { at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] } ]; let a = stops[0], b = stops[1]; for (let i = 0; i < stops.length - 1; i++) { @@ -3103,19 +3473,63 @@ const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); const eased = localT * localT * (3 - 2 * localT); const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); - const label = minute < 0.55 ? 'Morning' : minute < 5 ? 'Day' : minute < 6.10 ? 'Evening' : 'Night'; + const overlay = a.overlay.map((v, i) => lerp(v, b.overlay[i], eased)); + const darkness = lerp(a.darkness, b.darkness, eased); + const mixSky = mixHex(a.sky, b.sky, eased); + const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day')); + const label = dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day'; return { - key: label.toLowerCase(), + key: dominantKey, label, progress: t / DAY_MS, - sky: mixHex(a.sky, b.sky, eased), - darkness: lerp(a.darkness, b.darkness, eased), + sky: mixSky, + darkness, + darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`, tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, tintAlpha: tint[3], shadow: getShadowForMinute(minute) }; } + function areNightLightsActive(phase = renderPhase) { + return visualSettings().enableLights !== false && phase?.key === 'night'; + } + + function collectLightSourcesForItems(items, time, phase) { + if (!areNightLightsActive(phase) || !items?.length) return []; + const sources = []; + for (const item of items) { + const asset = item.asset; + if (!asset.meta?.hasLight || !asset.meta.lightPixels?.length) continue; + const info = getSpriteDrawInfo(item, time, true); + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const pivotX = info.drawX + info.sprite.width / 2; + const pivotY = info.drawY + info.sprite.height * 0.8; + for (const light of asset.meta.lightPixels) { + const lx = asset.category === 'dynamic' && info.side === 'left' ? asset.size - 1 - light.x : light.x; + let wx = info.drawX + (lx + 0.5) * scale; + let wy = info.drawY + (light.y + 0.5) * scale; + if (info.angle) { + const dx = wx - pivotX; + const dy = wy - pivotY; + const cos = Math.cos(info.angle); + const sin = Math.sin(info.angle); + wx = pivotX + dx * cos - dy * sin; + wy = pivotY + dx * sin + dy * cos; + } + sources.push({ + x: wx, + y: wy, + color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), + radius: lerp(13, 22, asset.size / 64), + ownerId: item.source?.id || asset.id, + flickerSeed: parseInt(fnv1a(`${item.source?.id || asset.id}:${light.x},${light.y}`).slice(0, 6), 16) || 1 + }); + } + } + return sources; + } + function render(time = performance.now()) { const phase = getPhase(); renderPhase = phase; @@ -3129,7 +3543,6 @@ ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; drawTerrainCache(terrainCache); - if (visualSettings().enableParticles) drawCoastalFoam(time); drawHoverTile(); const lightSources = []; const visibleItems = drawObjects(time, lightSources, phase); @@ -3141,7 +3554,7 @@ drawSpawnEffects(time); drawConfettiParticles(time); } - if (visualSettings().enableLights) drawSurfaceLightBleed(visibleItems, lightSources, time); + const nightLightsActive = areNightLightsActive(phase); ctx.restore(); updateSelectionBubble(time); @@ -3150,10 +3563,10 @@ ctx.fillRect(0, 0, cw, ch); } if (phase.darkness > 0) { - ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`; + ctx.fillStyle = phase.darkOverlay || `rgba(12, 19, 45, ${phase.darkness})`; ctx.fillRect(0, 0, cw, ch); } - if (visualSettings().enableLights && phase.darkness > 0) drawLightSources(lightSources, phase.darkness); + if (nightLightsActive && phase.darkness > 0) drawLightSources(lightSources, phase.darkness, time); } function drawHoverTile() { @@ -3197,15 +3610,17 @@ function drawObjects(time, lightSources, phase) { const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); + const activeLights = collectLightSourcesForItems(items, time, phase); + if (lightSources && activeLights.length) lightSources.push(...activeLights); for (const item of items) { if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { - drawSpriteItem(item, time, lightSources, true, phase); + drawSpriteItem(item, time, activeLights, true, phase); } } for (const item of items) { if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) { - drawSpriteItem(item, time, lightSources, false, phase); + drawSpriteItem(item, time, activeLights, false, phase); } } return items; @@ -3217,33 +3632,42 @@ const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird'); if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y); let bob = 0; - if (includeSelectBounce && asset.category === 'static' && selectedObject?.id === item.source?.id) { - const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 520, 0, 1); - if (selectedT < 1) bob += -Math.sin(selectedT * Math.PI) * 8; - } let alpha = 1; let side = 'right'; let angle = 0; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6; if (asset.category === 'dynamic') { - const runtime = item.source; - side = (runtime.facing || runtime.vx || 1) < 0 ? 'left' : 'right'; + const runtime = item.source || {}; + const seed = Number.isFinite(runtime.seed) ? runtime.seed : 0; + const isPreview = Boolean(item.preview || runtime.preview); + const moveFacing = Math.abs(runtime.lastMoveX || 0) > 0.003 ? Math.sign(runtime.lastMoveX) : (runtime.facing || runtime.vx || 1); + side = isPreview ? 'right' : (moveFacing < 0 ? 'left' : 'right'); if (asset.subtype === 'human' || asset.subtype === 'animal') { - const idle = time < (runtime.idleUntil || 0); + const idle = isPreview || time < (runtime.idleUntil || 0); if (!idle) { - const jumpPhase = time / 78 + runtime.seed * 0.9; + const jumpPhase = time / 78 + seed * 0.9; const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); const liftNow = getLiftAtCoord(item.x, item.y); const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); const terrainBoost = Math.abs(liftTarget - liftNow) / 12; bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); - const rand = pseudoNoise(cycle + runtime.seed * 13.17); + const rand = pseudoNoise(cycle + seed * 13.17); angle = (rand - 0.5) * 0.24 * hop; } } - if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5; - if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; } + if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + seed) * 4.5; + if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + seed) * 1.7; } + } + if (includeSelectBounce && selectedObject?.id === item.source?.id) { + const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 560, 0, 1); + if (selectedT < 1) { + const bounce = Math.sin(selectedT * Math.PI); + bob += -bounce * 8; + const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1; + const tiltSign = pseudoNoise(tiltSeed) > 0.5 ? 1 : -1; + angle += tiltSign * bounce * 0.18; + } } const sprite = getSpriteCanvas(asset, side); const drawX = pos.x - sprite.width / 2; @@ -3253,11 +3677,12 @@ } function drawSpriteItem(item, time, lightSources, underwater, phase) { - const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); + const info = getSpriteDrawInfo(item, time, true); + const { asset, pos, sprite, drawX, drawY, alpha, angle } = info; if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id); - if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); + if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(info, phase); ctx.save(); ctx.globalAlpha = alpha; @@ -3275,66 +3700,98 @@ } ctx.restore(); - if (asset.meta?.hasLight && asset.meta.lightPixels?.length) { - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - for (const light of asset.meta.lightPixels) { - const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x; - const wx = drawX + (lx + 0.5) * scale; - const wy = drawY + (light.y + 0.5) * scale; - lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(10, 16, asset.size / 64), ownerId: item.source?.id || asset.id }); - } - } + drawSpriteLightReflection(info, item, lightSources, phase); } - function drawSurfaceLightBleed(items, lightSources, time) { - if (!items?.length || !lightSources?.length) return; - for (const item of items) { - const info = getSpriteDrawInfo(item, time, false); - const sprite = info.sprite; - const localSources = []; - for (const source of lightSources) { - if (source.ownerId === item.source?.id) continue; - const dx = source.x - (info.drawX + sprite.width / 2); - const dy = source.y - (info.drawY + sprite.height / 2); - const reach = source.radius * 2.2 + Math.max(sprite.width, sprite.height) * 0.55; - const distance = Math.hypot(dx, dy); - if (distance > reach) continue; - localSources.push({ source, distance }); - } - if (!localSources.length) continue; - localSources.sort((a, b) => a.distance - b.distance); - const overlay = document.createElement('canvas'); - overlay.width = sprite.width; - overlay.height = sprite.height; - const octx = overlay.getContext('2d'); - octx.imageSmoothingEnabled = false; - octx.drawImage(sprite, 0, 0); - octx.globalCompositeOperation = 'source-atop'; - for (const { source, distance } of localSources.slice(0, 2)) { - const localX = source.x - info.drawX; - const localY = source.y - info.drawY; - const radius = Math.max(12, source.radius * 1.7); - const influence = 1 - clamp(distance / (source.radius * 2.4 + 36), 0, 1); - if (influence <= 0.02) continue; - const grad = octx.createRadialGradient(localX, localY, 0, localX, localY, radius); - grad.addColorStop(0, hexToRgba(source.color, 0.22 * influence)); - grad.addColorStop(0.5, hexToRgba(source.color, 0.10 * influence)); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - octx.fillStyle = grad; - octx.fillRect(0, 0, overlay.width, overlay.height); - } - ctx.save(); - ctx.globalAlpha = 0.9; - if (info.angle) { - ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); - ctx.rotate(info.angle); - ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); - } else { - ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); - } - ctx.restore(); + function spriteLocalPoint(info, worldX, worldY) { + if (!info.angle) return { x: worldX - info.drawX, y: worldY - info.drawY }; + const pivotX = info.drawX + info.sprite.width / 2; + const pivotY = info.drawY + info.sprite.height * 0.8; + const dx = worldX - pivotX; + const dy = worldY - pivotY; + const cos = Math.cos(-info.angle); + const sin = Math.sin(-info.angle); + return { + x: dx * cos - dy * sin + info.sprite.width / 2, + y: dx * sin + dy * cos + info.sprite.height * 0.8 + }; + } + + function drawSpriteLightReflection(info, item, lightSources, phase) { + if (!areNightLightsActive(phase) || !lightSources?.length) return; + const asset = item.asset; + const ownerId = item.source?.id || asset.id; + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const sprite = info.sprite; + const sources = []; + for (const source of lightSources) { + if (source.ownerId === ownerId) continue; + const local = spriteLocalPoint(info, source.x, source.y); + const dx = local.x - sprite.width / 2; + const dy = local.y - sprite.height / 2; + const reach = source.radius * 4.9 + Math.max(sprite.width, sprite.height) * 1.15; + const distance = Math.hypot(dx, dy); + if (distance <= reach) sources.push({ source, local, distance }); } + if (!sources.length) return; + sources.sort((a, b) => a.distance - b.distance); + + const pixels = getAssetPixels(asset, info.side || 'right'); + const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); + const depths = asset.category === 'dynamic' && info.side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths; + const overlay = document.createElement('canvas'); + overlay.width = sprite.width; + overlay.height = sprite.height; + const octx = overlay.getContext('2d'); + octx.imageSmoothingEnabled = false; + + for (let y = 0; y < asset.size; y++) { + for (let x = 0; x < asset.size; x++) { + if (!pixels[y * asset.size + x]) continue; + const depth = depths[y * asset.size + x] || 0; + let rr = 0, gg = 0, bb = 0, aa = 0; + for (const { source, local } of sources.slice(0, 3)) { + const lc = parseHex(source.color || '#ffd86a'); + if (!lc) continue; + const cellPx = (x + 0.5) * scale; + const cellPy = (y + 0.5) * scale; + const d = Math.hypot(local.x - cellPx, local.y - cellPy); + const reach = Math.max(scale * 2.2, source.radius * 3.8); + const t = 1 - clamp(d / reach, 0, 1); + if (t <= 0) continue; + const edge = getDepthLightFacing(depth, x, y, asset.size, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5); + const depthFactor = depth > 0 ? 1.05 + edge * 0.38 : depth < 0 ? 0.72 + edge * 0.18 : 0.50 + edge * 0.10; + const amount = t * t * depthFactor; + rr += lc.r * amount; + gg += lc.g * amount; + bb += lc.b * amount; + aa += amount; + } + if (aa <= 0.002) continue; + const inv = 1 / aa; + const alpha = Math.min(0.34, 0.08 + aa * 0.18); + octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${alpha.toFixed(3)})`; + octx.fillRect(x * scale, y * scale, scale, scale); + } + } + + ctx.save(); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + if (info.angle) { + ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); + ctx.rotate(info.angle); + ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); + } else { + ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); + } + ctx.restore(); + } + + function drawSurfaceLightBleed(items, lightSources, time) { + // Kept as a no-op compatibility stub. Reflections are now drawn per sprite + // immediately after that sprite, so light overlays preserve draw order. } function spawnGroundStepParticles(item, time, tile) { @@ -3374,20 +3831,22 @@ if (view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles) return; for (const item of items) { - const emitters = item.asset.meta?.particlePixels || []; - if (!emitters.length) continue; + const legacyEmitters = item.asset.meta?.particlePixels || []; + if (!legacyEmitters.length) continue; const info = getSpriteDrawInfo(item, time, false); const scale = item.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - for (const emitter of emitters) { - if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles || Math.random() > 0.018) continue; + for (const emitter of legacyEmitters) { + if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles || Math.random() > 0.012) continue; const ex = item.asset.category === 'dynamic' && info.side === 'left' ? item.asset.size - 1 - emitter.x : emitter.x; const wx = info.drawX + (ex + 0.5) * scale; const wy = info.drawY + (emitter.y + 0.5) * scale; + const velocity = particleVelocityForDirection(emitter.dir || 'up'); natureDriftParticles.push({ x: wx + (Math.random() - 0.5) * 4, y: wy + (Math.random() - 0.5) * 4, - vx: -3 + Math.random() * 6, - vy: 6 + Math.random() * 12, + vx: velocity.vx + (Math.random() - 0.5) * 4, + vy: velocity.vy + (Math.random() - 0.5) * 4, + gravity: 4, started: time, life: 1500 + Math.random() * 1600, color: colorToHex(emitter.c || selectedColorCode), @@ -3397,6 +3856,14 @@ } } + function particleVelocityForDirection(dir) { + const speed = 13; + if (dir === 'down') return { vx: 0, vy: speed }; + if (dir === 'left') return { vx: -speed, vy: 0 }; + if (dir === 'right') return { vx: speed, vy: 0 }; + return { vx: 0, vy: -speed }; + } + function drawNatureDriftParticles(time) { if (!natureDriftParticles.length || view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; ctx.save(); @@ -3406,7 +3873,7 @@ const seconds = (time - particle.started) / 1000; const sway = Math.sin(age * 5.5 + particle.sway) * 3.5; const x = particle.x + particle.vx * seconds + sway; - const y = particle.y + particle.vy * seconds + age * 5; + const y = particle.y + particle.vy * seconds + age * (particle.gravity ?? 5); ctx.globalAlpha = (1 - age) * 0.72; ctx.fillStyle = particle.color; ctx.fillRect(Math.round(x), Math.round(y), 1, 1); @@ -3452,20 +3919,22 @@ - function drawSpriteShadow(drawX, drawY, sprite, phase) { + function drawSpriteShadow(info, phase) { + const { drawX, sprite, pos, anchorY, bob } = info; const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 }; + if ((shadow.alpha || 0) <= 0.001) return; const silhouette = getShadowCanvas(sprite); const contactX = drawX + sprite.width / 2; - const contactY = drawY + sprite.height; + // Keep the mirror line on the ground. When a selected sprite jumps upward, + // the reflected silhouette moves downward by the same amount instead of floating. + const groundY = pos.y + anchorY; + const mirrorOffset = Math.max(0, -(bob || 0)); ctx.save(); ctx.globalAlpha = shadow.alpha; - // The silhouette is vertically flipped in getShadowCanvas(). - // Drawing it at y=0 means the flipped top edge, which was the sprite's bottom edge, - // exactly touches the sprite's bottom/contact line. - ctx.translate(contactX, contactY); + ctx.translate(contactX, groundY); ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); - ctx.drawImage(silhouette, Math.round(-sprite.width / 2), 0); + ctx.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset)); ctx.restore(); } @@ -3565,31 +4034,99 @@ return { pos, asset, objectId: selectedObject.id }; } + + function findRemixSourceObject(asset) { + const ids = [asset?.parentAssetId, asset?.originalAssetId].filter(Boolean); + for (const assetId of ids) { + const placed = (state.placed || []).find((p) => p.assetId === assetId && !state.hiddenObjects?.[p.id]); + if (placed) return { kind: 'static', object: placed, asset: findAsset(assetId), x: placed.x + .5, y: placed.y + .5 }; + const summon = (state.dynamicSummons || []).find((p) => p.assetId === assetId && !state.hiddenObjects?.[p.id]); + if (summon) return { kind: 'dynamic', object: summon, asset: findAsset(assetId), x: summon.homeX + .5, y: summon.homeY + .5 }; + } + return null; + } + + function centerViewOnWorldCoord(x, y, zoom = null) { + const pos = tileToWorld(x, y); + pos.y -= getLiftAtCoord(x, y); + if (zoom != null) view.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM); + view.x = cw / 2 - pos.x * view.zoom; + view.y = ch / 2 - (pos.y - 32) * view.zoom; + render(performance.now()); + } + + function teleportToRemixSource() { + if (!selectedObject) return; + const asset = findAsset(selectedObject.assetId); + const source = findRemixSourceObject(asset); + if (!source || !source.object || !source.asset) { + toast('Remix source is not placed on the island right now.'); + return; + } + selectWorldObject(source.kind, source.object.id, source.asset.id, performance.now()); + centerViewOnWorldCoord(source.x, source.y, Math.max(view.zoom, 1.05)); + toast(`Teleported to ${source.asset.name || 'remix source'}.`); + } + + function toggleBubbleMenu() { + if (!els.bubbleMenuActions) return; + els.bubbleMenuActions.hidden = !els.bubbleMenuActions.hidden; + } + + function getSelectedDrawableItem(time = performance.now()) { + if (!selectedObject) return null; + const items = getDrawableItems(time, getViewportWorldRect(512)); + return items.find((item) => item.source?.id === selectedObject.id && item.asset?.id === selectedObject.assetId) || null; + } + + function getSelectedBubbleAnchor(time = performance.now()) { + const item = getSelectedDrawableItem(time); + if (!item) return null; + const info = getSpriteDrawInfo(item, time, true); + const topPad = Math.max(14, Math.min(30, info.sprite.height * 0.18)); + return { + x: info.drawX + info.sprite.width / 2, + y: info.drawY - topPad, + asset: item.asset, + objectId: item.source?.id || selectedObject.id + }; + } + function updateSelectionBubble(time) { if (!els.selectionBubble) return; const selected = getSelectedWorldPosition(); if (!selected) { els.selectionBubble.hidden = true; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } - const { pos, asset, objectId } = selected; - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const topOffset = Math.max(18, asset.size * scale + 26); - const sx = pos.x * view.zoom + view.x; - const sy = (pos.y - topOffset) * view.zoom + view.y; - if (sx < -100 || sy < -100 || sx > cw + 100 || sy > ch + 100) { + const anchor = getSelectedBubbleAnchor(time) || { x: selected.pos.x, y: selected.pos.y - Math.max(48, (selected.asset.size || 8) * 2), asset: selected.asset, objectId: selected.objectId }; + const { asset, objectId } = anchor; + const sx = anchor.x * view.zoom + view.x; + const sy = anchor.y * view.zoom + view.y; + if (sx < -120 || sy < -160 || sx > cw + 120 || sy > ch + 120) { els.selectionBubble.hidden = true; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } els.selectionBubble.hidden = false; - els.selectionBubble.style.transform = `translate(${Math.round(sx)}px, ${Math.round(sy)}px) translate(-50%, -100%)`; + const bubbleX = clamp(sx, 96, cw - 96); + const bubbleY = clamp(sy, 28, ch - 118); + els.selectionBubble.style.left = `${Math.round(bubbleX)}px`; + els.selectionBubble.style.top = `${Math.round(bubbleY)}px`; + els.selectionBubble.style.transform = 'translate(-50%, -100%)'; els.bubbleName.textContent = asset.name || 'Untitled'; els.bubbleAuthor.textContent = `by ${asset.author || 'Local Artist'}`; const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null; + const remixSource = findRemixSourceObject(asset); if (els.bubbleRemixFrom) { els.bubbleRemixFrom.hidden = !parent; els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : ''; } + if (els.bubbleTeleport) { + els.bubbleTeleport.hidden = !remixSource; + els.bubbleTeleport.textContent = remixSource?.asset ? `Teleport to ${remixSource.asset.name || 'source'}` : 'Teleport to source'; + } if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`; const votes = getObjectVoteCounts(objectId); const voter = currentVoterKey(); @@ -3808,20 +4345,22 @@ ctx.restore(); } - function drawLightSources(sources, darkness) { + function drawLightSources(sources, darkness, time = performance.now()) { ctx.save(); for (const source of sources) { const sx = source.x * view.zoom + view.x; const sy = source.y * view.zoom + view.y; - const radius = source.radius * view.zoom; - const auraRadius = radius * 1.28; + const flicker = 0.94 + pseudoNoise((time * 0.0017) + (source.flickerSeed || 0)) * 0.12; + const radius = source.radius * view.zoom * flicker; + const intensity = 0.68 + (flicker - 0.94) * 0.28; + const auraRadius = radius * 1.12; if (sx < -auraRadius || sy < -auraRadius || sx > cw + auraRadius || sy > ch + auraRadius) continue; // Light cores are drawn after the night overlay and do not inherit night darkness. ctx.globalCompositeOperation = 'source-over'; const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48)); - core.addColorStop(0, hexToRgba(source.color, .84)); - core.addColorStop(.34, hexToRgba(source.color, .44)); + core.addColorStop(0, hexToRgba(source.color, .48 * intensity)); + core.addColorStop(.34, hexToRgba(source.color, .22 * intensity)); core.addColorStop(1, hexToRgba(source.color, .00)); ctx.fillStyle = core; ctx.beginPath(); @@ -3830,8 +4369,8 @@ ctx.globalCompositeOperation = 'lighter'; const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, auraRadius); - glow.addColorStop(0, hexToRgba(source.color, .22)); - glow.addColorStop(.42, hexToRgba(source.color, .10)); + glow.addColorStop(0, hexToRgba(source.color, .13 * intensity)); + glow.addColorStop(.42, hexToRgba(source.color, .05 * intensity)); glow.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = glow; ctx.beginPath(); @@ -3842,10 +4381,14 @@ } function getSpriteCanvas(asset, side) { - const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}` : 'day'; + const lightsOn = areNightLightsActive(renderPhase); + const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}` : 'day:unlit'; const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; if (spriteCache.has(key)) return spriteCache.get(key); const pixels = getAssetPixels(asset, side); + const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); + const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths; + const lights = lightsOn ? getAssetLightPointsForSide(asset, side) : []; const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const canvas = document.createElement('canvas'); canvas.width = asset.size * scale; @@ -3856,7 +4399,8 @@ for (let x = 0; x < asset.size; x++) { const color = pixels[y * asset.size + x]; if (!color) continue; - c.fillStyle = shadeAssetPixelColor(colorToHex(color), getAssetDepth(asset, x, y, side), x, y, asset.size, renderPhase); + const depth = depths[y * asset.size + x] || 0; + c.fillStyle = shadeAssetPixelColor(colorToHex(color), depth, x, y, asset.size, renderPhase, pixels, depths, lights); c.fillRect(x * scale, y * scale, scale, scale); } } @@ -4014,23 +4558,33 @@ return canvas; } - function drawCoastalFoam(time) { + function drawCoastalFoam(time) { return; + /* disabled by request */ + if (!terrainCache?.coastTiles?.length || !coastalFoamTextures) return; const rect = getViewportWorldRect(64); + const t = time * 0.001; for (const tile of terrainCache.coastTiles) { const pos = tileToWorld(tile.x, tile.y); - if (pos.x + 22 < rect.left || pos.x - 22 > rect.right || pos.y + 18 < rect.top || pos.y - 18 > rect.bottom) continue; + if (pos.x + 28 < rect.left || pos.x - 28 > rect.right || pos.y + 24 < rect.top || pos.y - 24 > rect.bottom) continue; + const seed = (tile.x * 17.17 + tile.y * 7.91); + const drift = Math.sin(t * 2.4 + seed) * 5.0; + const bob = Math.cos(t * 2.9 + seed * .7) * 2.1; + const crawl = Math.sin(t * 1.25 + seed * .41) * 3.4; + const pulse = 0.68 + Math.sin(t * 3.1 + seed * .31) * 0.32; const baseY = pos.y + TILE_H * 0.42; ctx.save(); - ctx.globalAlpha = 0.34; - ctx.translate(pos.x, baseY); - ctx.rotate(time / 12000 + (tile.x + tile.y) * 0.03); + ctx.globalAlpha = 0.20 + 0.18 * pulse; + ctx.translate(pos.x + drift, baseY + bob + crawl * .25); + ctx.rotate(time / 9500 + (tile.x + tile.y) * 0.035); + ctx.scale(0.92 + pulse * 0.12, 0.86 + pulse * 0.10); ctx.drawImage(coastalFoamTextures.a, -12, -12); ctx.restore(); ctx.save(); - ctx.globalAlpha = 0.22; - ctx.translate(pos.x + 1, baseY + 1); - ctx.rotate(-(time / 16500) + (tile.x - tile.y) * 0.025); + ctx.globalAlpha = 0.14 + 0.12 * (1 - pulse); + ctx.translate(pos.x - drift * 0.65 + crawl * .35, baseY + 1 - bob * 0.7); + ctx.rotate(-(time / 12500) + (tile.x - tile.y) * 0.03); + ctx.scale(0.78 + (1 - pulse) * 0.18, 0.78 + pulse * 0.12); ctx.drawImage(coastalFoamTextures.b, -12, -12); ctx.restore(); } @@ -4184,7 +4738,7 @@ eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], sync: input.sync || { lastEventId: null }, settings: { ...defaultVisualSettings(), ...(input.settings || {}) }, - account: input.account || null, + account: normalizeAccount(input.account), publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [] }; } @@ -4220,15 +4774,15 @@ .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - const particlePixels = Array.isArray(meta.particlePixels) + const legacyParticlePixels = Array.isArray(meta.particlePixels) ? meta.particlePixels - .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff') })) + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size, particlePixels); + return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size, legacyParticlePixels); } - function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticlePixels = []) { + function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticle = null) { const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); const hasDepth = normalizedDepth.some(Boolean); const lightPixelsClean = Array.isArray(sourceLightPixels) @@ -4236,19 +4790,25 @@ .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - const particlePixelsClean = Array.isArray(sourceParticlePixels) - ? sourceParticlePixels - .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff') })) + const legacyParticlePixels = Array.isArray(sourceParticle) + ? sourceParticle + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; + const particleSource = !Array.isArray(sourceParticle) && sourceParticle + ? sourceParticle + : (legacyParticlePixels.length ? { enabled: true, c: legacyParticlePixels[0].c, dir: legacyParticlePixels[0].dir || 'up' } : null); + const particleClean = normalizeParticleConfig(particleSource || { enabled: false, c: nearestPaletteCode('#ffffff'), dir: 'up' }, size); + const particleCells = legacyParticlePixels.map((p) => ({ x: p.x, y: p.y, c: PALETTE_BY_CODE[p.c] ? p.c : nearestPaletteCode('#ffffff'), dir: ['up','down','left','right'].includes(p.dir) ? p.dir : (particleClean.dir || 'up') })); const hasLight = lightPixelsClean.length > 0; - const hasParticles = particlePixelsClean.length > 0; + const hasParticles = particleCells.length > 0; return { hasLight, lightPixels: hasLight ? lightPixelsClean : [], lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, hasParticles, - particlePixels: hasParticles ? particlePixelsClean : [], + particlePixels: hasParticles ? particleCells : [], + particleConfig: { enabled: hasParticles, c: particleCells[0]?.c || particleClean.c, dir: particleCells[0]?.dir || particleClean.dir }, depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null, door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null }; @@ -4370,7 +4930,7 @@ createdAt: Date.now(), updatedAt: Date.now(), author: 'Island Team', - meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particlePixels || []) + meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particleConfig || meta.particlePixels || []) }; asset.contentHash = computeAssetContentHash(asset); return asset; @@ -4630,6 +5190,7 @@ asset.meta?.depthPixels || '', JSON.stringify(asset.meta?.lightPixels || []), JSON.stringify(asset.meta?.particlePixels || []), + JSON.stringify(asset.meta?.particleConfig || null), asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' ].join('|'); return `fnv1a:${fnv1a(payload)}`; @@ -4764,23 +5325,123 @@ return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase); } - function shadeAssetPixelColor(hex, depth, x, y, size, phase) { + function shadeAssetPixelColor(hex, depth, x, y, size, phase, pixels = null, depths = null, lights = []) { const rgb = parseHex(hex); if (!rgb) return hex; const activePhase = phase || renderPhase || getPhase(); const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1); const vertical = 1 - (y / Math.max(1, size - 1)); const horizontal = ((x / Math.max(1, size - 1)) - 0.5) * dirX; - const sunlight = (activePhase?.key === 'night' ? 8 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 12 : 16); - const depthBoost = depth * sunlight; - const exposure = (vertical * 0.7 + horizontal * 0.5) * (activePhase?.key === 'night' ? 9 : 13); - const amt = depthBoost + exposure; - const r = clamp(rgb.r + amt, 0, 255); - const g = clamp(rgb.g + amt, 0, 255); - const b = clamp(rgb.b + amt, 0, 255); + const sunlight = (activePhase?.key === 'night' ? 6 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 9 : 11); + const depthBoost = depth * sunlight * 0.35; + const exposure = (vertical * 0.62 + horizontal * 0.5) * (activePhase?.key === 'night' ? 6 : 10); + const edgeBoost = getDepthEdgeLightBoost(depth, x, y, size, pixels, depths, lights, activePhase); + let r = rgb.r + depthBoost + exposure + edgeBoost; + let g = rgb.g + depthBoost + exposure + edgeBoost; + let b = rgb.b + depthBoost + exposure + edgeBoost; + + if (areNightLightsActive(activePhase) && lights?.length) { + for (const light of lights) { + const lc = parseHex(light.c || '#ffd86a'); + if (!lc) continue; + const dist = Math.hypot((light.x + 0.5) - (x + 0.5), (light.y + 0.5) - (y + 0.5)); + const reach = Math.max(2.25, size * 0.42); + const t = 1 - clamp(dist / reach, 0, 1); + if (t <= 0) continue; + const edge = getDepthLightFacing(depth, x, y, size, pixels, depths, light.x, light.y); + const depthFactor = depth > 0 ? 1.08 + edge * 0.30 : depth < 0 ? 0.72 + edge * 0.16 : 0.54 + edge * 0.08; + const strength = t * t * (10 + Math.abs(depth) * 7) * depthFactor; + const mix = Math.min(0.52, 0.12 + t * (depth ? 0.24 : 0.18)); + r = lerp(r, lc.r + strength, mix); + g = lerp(g, lc.g + strength * 0.82, mix * 0.94); + b = lerp(b, lc.b + strength * 0.74, mix * 0.88); + } + } + + r = clamp(r, 0, 255); + g = clamp(g, 0, 255); + b = clamp(b, 0, 255); return `#${Math.round(r).toString(16).padStart(2,'0')}${Math.round(g).toString(16).padStart(2,'0')}${Math.round(b).toString(16).padStart(2,'0')}`; } + + function mirrorDepthPixels(depths, size) { + const source = normalizeDepthPixels(depths || [], size); + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) out[y * size + (size - 1 - x)] = source[y * size + x] || 0; + } + return out; + } + + function getAssetLightPointsForSide(asset, side = 'right') { + const size = asset.size || 1; + const lights = Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels : []; + return lights.map((p) => ({ + x: asset.category === 'dynamic' && side === 'left' ? size - 1 - p.x : p.x, + y: p.y, + c: colorToHex(p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a')) + })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)); + } + + function isObjectOutlinePixel(pixels, x, y, size) { + if (!pixels || !pixels[y * size + x]) return false; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= size || ny >= size) return true; + if (!pixels[ny * size + nx]) return true; + } + return false; + } + + function collectDepthEdgeDirs(x, y, size, pixels, depths, depth) { + if (!pixels || !depths) return []; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const edgeDirs = []; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + const neighborInside = nx >= 0 && ny >= 0 && nx < size && ny < size; + const neighborOpaque = neighborInside && pixels[ny * size + nx]; + const neighborDepth = neighborOpaque ? (depths[ny * size + nx] || 0) : 0; + if (!neighborOpaque || neighborDepth !== depth) edgeDirs.push([dx, dy]); + } + return edgeDirs; + } + + function getDepthLightFacing(depth, x, y, size, pixels, depths, lightX, lightY) { + if (!depth || !pixels || !depths) return 0; + const edgeDirs = collectDepthEdgeDirs(x, y, size, pixels, depths, depth); + if (!edgeDirs.length) return 0; + const lx = lightX - x; + const ly = lightY - y; + const len = Math.hypot(lx, ly) || 1; + const vx = lx / len, vy = ly / len; + let bestDot = -1; + for (const [dx, dy] of edgeDirs) bestDot = Math.max(bestDot, dx * vx + dy * vy); + return depth > 0 ? Math.max(0, bestDot) : Math.max(0, -bestDot); + } + + function getDepthEdgeLightBoost(depth, x, y, size, pixels, depths, lights, phase) { + if (!depth || !pixels || !depths || !areNightLightsActive(phase) || !lights?.length) return 0; + let best = null, bestD = Infinity; + for (const light of lights) { + const d = Math.hypot(light.x - x, light.y - y); + if (d < bestD) { bestD = d; best = light; } + } + if (!best) return 0; + const facing = getDepthLightFacing(depth, x, y, size, pixels, depths, best.x, best.y); + if (!facing) return 0; + const outerEdge = collectDepthEdgeDirs(x, y, size, pixels, depths, depth).some(([dx, dy]) => { + const nx = x + dx, ny = y + dy; + return nx < 0 || ny < 0 || nx >= size || ny >= size || !pixels[ny * size + nx]; + }); + const distanceFalloff = 1 - clamp(bestD / Math.max(2.25, size * 0.44), 0, 1); + if (distanceFalloff <= 0) return 0; + const strength = outerEdge ? 0.64 : 1; + return facing * strength * distanceFalloff * distanceFalloff * (depth > 0 ? 20 : 14); + } + function resizePixels(source, oldSize, newSize) { const out = blankPixels(newSize); const min = Math.min(oldSize, newSize); diff --git a/index.html b/index.html index 3d6d6f2..eefd087 100644 --- a/index.html +++ b/index.html @@ -9,41 +9,44 @@
+
Left-click: select · Right-click: clear
- + -
-
+
+
-
Local prototype / Draw pixels, summon them to islands.
+
A rotating island exhibition for tiny pixel works.
- -
-
Day
-
-
10 min = 1 day
+
+
+ + + +
+ + Save + Place creates a local ID, name, and password. Change the password after creation.
- +
+
+ +
+
Day
+ +
@@ -55,9 +58,9 @@
@@ -102,33 +105,49 @@
- - - - - - - - - + + + +
- - - - - - + + +
+
- - - - - - - - + + + + + + + +
Palette color is used for pixels, lights, particles, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.
@@ -138,9 +157,7 @@
Finish
- - - +
@@ -149,7 +166,7 @@
-
Library
+
Collection

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 @@
-
+
Local save
-

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.

@@ -221,7 +238,11 @@
- + + +
+ + diff --git a/js/phase2-sync.js b/js/phase2-sync.js index 1ae0e8e..0300bb1 100644 --- a/js/phase2-sync.js +++ b/js/phase2-sync.js @@ -2,21 +2,134 @@ 'use strict'; const root = window.PixelIslandModules ||= {}; - const FORMAT = 'pixel-island-phase2-compact-v1'; + const FORMAT = 'pixel-island-phase2-compact-v2'; + const FORMAT_V1 = 'pixel-island-phase2-compact-v1'; const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1'; - const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1'; + const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2'; + const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1'; const EVENT_LOG_LIMIT = 300; const DB_NAME = 'pixel-island-phase2-cache'; const DB_VERSION = 1; const ASSET_STORE = 'assets'; const SNAPSHOT_STORE = 'snapshots'; + const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const PACK6_CHARS = `.${COLOR_CODES}`; function isCompactState(value) { - return Boolean(value && value.format === FORMAT && Array.isArray(value.assets)); + return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets)); } function isAssetBundle(value) { - return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets)); + return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets)); + } + + function bytesToBase64(bytes) { + if (!bytes || !bytes.length) return ''; + if (typeof btoa === 'function') { + let binary = ''; + const chunk = 8192; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.slice(i, i + chunk)); + } + return btoa(binary); + } + if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64'); + return ''; + } + + function base64ToBytes(value) { + const text = String(value || ''); + if (!text) return []; + if (typeof atob === 'function') { + const binary = atob(text); + const out = new Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255; + return out; + } + if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64')); + return []; + } + + function pack6Index(ch) { + const index = PACK6_CHARS.indexOf(ch || '.'); + return index >= 0 && index < 64 ? index : 0; + } + + function unpack6Index(value) { + return PACK6_CHARS[value] || '.'; + } + + function canPack6(text) { + for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false; + return true; + } + + function pack6Text(input) { + const text = String(input || ''); + if (!text) return ''; + let buffer = 0; + let bitCount = 0; + const bytes = []; + for (const ch of text) { + buffer = (buffer << 6) | pack6Index(ch); + bitCount += 6; + while (bitCount >= 8) { + bitCount -= 8; + bytes.push((buffer >> bitCount) & 255); + buffer &= (1 << bitCount) - 1; + } + } + if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255); + return bytesToBase64(bytes); + } + + function unpack6Text(input, length) { + const target = Math.max(0, Number(length) || 0); + if (!target) return ''; + const bytes = base64ToBytes(input); + let buffer = 0; + let bitCount = 0; + let out = ''; + for (const byte of bytes) { + buffer = (buffer << 8) | (byte & 255); + bitCount += 8; + while (bitCount >= 6 && out.length < target) { + bitCount -= 6; + out += unpack6Index((buffer >> bitCount) & 63); + buffer &= (1 << bitCount) - 1; + } + if (out.length >= target) break; + } + return (out + '.'.repeat(target)).slice(0, target); + } + + function packBitMask(mask) { + const bits = Array.from(mask || [], Boolean); + if (!bits.length) return ''; + const bytes = []; + let current = 0; + for (let i = 0; i < bits.length; i++) { + current = (current << 1) | (bits[i] ? 1 : 0); + if (i % 8 === 7) { + bytes.push(current & 255); + current = 0; + } + } + const rest = bits.length % 8; + if (rest) bytes.push((current << (8 - rest)) & 255); + return bytesToBase64(bytes); + } + + function unpackBitMask(input, length) { + const target = Math.max(0, Number(length) || 0); + const bytes = base64ToBytes(input); + const out = []; + for (const byte of bytes) { + for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1)); + if (out.length >= target) break; + } + while (out.length < target) out.push(false); + return out; } function rleEncode(input) { @@ -61,7 +174,17 @@ return (source + emptyChar.repeat(total)).slice(0, total); } - function cropPlane(encoded, size, emptyChar = '.') { + function chooseSmallest(candidates) { + let best = candidates[0]; + let bestSize = JSON.stringify(best).length; + for (const candidate of candidates.slice(1)) { + const size = JSON.stringify(candidate).length; + if (size < bestSize) { best = candidate; bestSize = size; } + } + return best; + } + + function cropPlane(encoded, size, emptyChar = '.', options = {}) { const text = normalizeEncodedPlane(encoded, size, emptyChar); let minX = size; let minY = size; @@ -81,42 +204,55 @@ const w = maxX - minX + 1; const h = maxY - minY + 1; let cropped = ''; - for (let y = minY; y <= maxY; y++) { - cropped += text.slice(y * size + minX, y * size + minX + w); - } - const rle = rleEncode(cropped); - return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped }; + for (let y = minY; y <= maxY; y++) cropped += text.slice(y * size + minX, y * size + minX + w); + const candidates = [ + { b: [minX, minY, w, h], e: 'raw', v: cropped }, + { b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) } + ]; + if (options.bitPack && canPack6(cropped)) candidates.push({ b: [minX, minY, w, h], e: 'bp6', v: pack6Text(cropped), n: cropped.length }); + return chooseSmallest(candidates); } - function expandPlane(packed, size, emptyChar = '.') { + function expandPlane(packed, size, emptyChar = '.', baseEncoded = null) { const total = Math.max(1, Number(size) || 1) ** 2; const out = Array(total).fill(emptyChar); if (!packed || !packed.b) return out.join(''); const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0)); - const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || ''); + let value = ''; + if (packed.e === 'rle') value = rleDecode(packed.v); + else if (packed.e === 'bp6') value = unpack6Text(packed.v, packed.n || (w * h)); + else value = String(packed.v || ''); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const src = y * w + x; const dx = x0 + x; const dy = y0 + y; - if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) { - out[dy * size + dx] = value[src] || emptyChar; - } + if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) out[dy * size + dx] = value[src] || emptyChar; } } return out.join(''); } - function packAsset(asset) { + function planeForAsset(asset) { + const size = Math.max(1, Number(asset?.size) || 16); + return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', size, '.'); + } + + function selectPixelPlanePack(asset, assetMap) { + const size = Math.max(1, Number(asset.size) || 16); + const right = planeForAsset(asset); + return cropPlane(right, size, '.', { bitPack: true }); + } + + function packAsset(asset, assetMap = null) { const size = Math.max(1, Number(asset.size) || 16); const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; - const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.'); const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : ''; const lights = Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2]) : []; const particles = Array.isArray(asset.meta?.particlePixels) - ? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2]) + ? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2]) : []; const meta = {}; if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.'); @@ -124,6 +260,10 @@ if (particles.length) meta.pt = particles; if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor; if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0]; + if (asset.meta?.particleConfig?.enabled && !particles.length) { + const pc = asset.meta.particleConfig; + meta.pc = [pc.c || '', pc.dir || 'up', null]; + } return { id: asset.id, @@ -132,7 +272,7 @@ c: category, t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), s: size, - p: cropPlane(right, size, '.'), + p: selectPixelPlanePack(asset, assetMap), f: category === 'dynamic' ? { l: 'mirror' } : null, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null, @@ -143,24 +283,29 @@ }; } - function unpackAsset(packed) { + function unpackAsset(packed, assetById = null) { if (!packed || !packed.id) return null; const size = Math.max(1, Number(packed.s || packed.size) || 16); const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static'; const pixels = expandPlane(packed.p, size, '.'); + if (pixels == null) return null; const metaPacked = packed.m || {}; const lightPixels = Array.isArray(metaPacked.l) ? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' })) : []; const particlePixels = Array.isArray(metaPacked.pt) - ? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' })) + ? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a', dir: p[3] || 'up' })) : []; + const particleConfig = Array.isArray(metaPacked.pc) + ? { enabled: true, c: metaPacked.pc[0] || 'a', dir: metaPacked.pc[1] || 'up', range: Array.isArray(metaPacked.pc[2]) ? { x: metaPacked.pc[2][0] || 0, y: metaPacked.pc[2][1] || 0, w: metaPacked.pc[2][2] || 0, h: metaPacked.pc[2][3] || 0 } : null } + : null; const meta = { hasLight: lightPixels.length > 0, lightPixels, lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null, - hasParticles: particlePixels.length > 0, + hasParticles: Boolean(particleConfig) || particlePixels.length > 0, particlePixels, + particleConfig, depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null, door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null }; @@ -182,6 +327,10 @@ }; } + function unpackAssets(rows) { + return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean); + } + function packPlacement(item) { const meta = {}; if (item.publishedAt) meta.pu = item.publishedAt; @@ -208,12 +357,18 @@ return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' }; } + function assetMapFor(assets) { + return new Map((assets || []).map((asset) => [asset.id, asset])); + } + function compactState(state) { + const assets = Array.isArray(state.assets) ? state.assets : []; + const map = assetMapFor(assets); return { - schema: 3, + schema: 4, format: FORMAT, authorName: state.authorName || 'Local Artist', - assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [], + assets: assets.map((asset) => packAsset(asset, map)), placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [], dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [], objectVotes: state.objectVotes || {}, @@ -233,9 +388,9 @@ function expandState(input) { if (!isCompactState(input)) return input; return { - schema: 3, + schema: input.schema || 4, authorName: input.authorName || 'Local Artist', - assets: input.assets.map(unpackAsset).filter(Boolean), + assets: unpackAssets(input.assets), placed: (input.placed || []).map(unpackPlacement), dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic), objectVotes: input.objectVotes || {}, @@ -253,7 +408,7 @@ } function compactSizeReport(state) { - const full = JSON.stringify({ ...state, schema: 3 }); + const full = JSON.stringify({ ...state, schema: 4 }); const compact = JSON.stringify(compactState(state)); return { fullBytes: full.length, @@ -266,12 +421,12 @@ } function assetManifest(state) { - return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype })); + return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null })); } function makeSnapshot(state, worldId = 'local-main') { return { - schema: 3, + schema: 4, format: SNAPSHOT_FORMAT, worldId, createdAt: Date.now(), @@ -284,13 +439,14 @@ function makeAssetBundle(state, assetIds) { const wanted = new Set(assetIds || []); - const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset); - return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets }; + const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)); + const map = assetMapFor(assets); + return { schema: 4, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets: assets.map((asset) => packAsset(asset, map)) }; } function unpackAssetBundle(bundle) { if (!isAssetBundle(bundle)) return []; - return bundle.assets.map(unpackAsset).filter(Boolean); + return unpackAssets(bundle.assets); } function findMissingAssetIds(snapshot, knownAssetIds) { @@ -306,6 +462,10 @@ return makeEvent('asset.upsert', { asset: packAsset(asset) }); } + function createAssetDeleteEvent(assetId) { + return makeEvent('asset.delete', { assetId }); + } + function createObjectUpsertEvent(kind, object) { return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) }); } @@ -314,15 +474,34 @@ return makeEvent('object.delete', { kind, objectId }); } + function deleteAssetOnly(state, assetId) { + if (!state || !assetId) return state; + const removedObjectIds = new Set(); + for (const item of state.placed || []) if (item.assetId === assetId) removedObjectIds.add(item.id); + for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removedObjectIds.add(item.id); + state.assets = (state.assets || []).filter((asset) => asset.id !== assetId); + state.placed = (state.placed || []).filter((item) => item.assetId !== assetId); + state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId); + delete state.assetVotes?.[assetId]; + delete state.hiddenAssets?.[assetId]; + for (const id of removedObjectIds) delete state.objectVotes?.[id], delete state.hiddenObjects?.[id]; + state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); + return state; + } + function applyEvent(state, event) { if (!state || !event) return state; if (event.type === 'asset.upsert' && event.asset) { - const asset = unpackAsset(event.asset); + const byId = assetMapFor(state.assets || []); + const asset = unpackAsset(event.asset, byId); if (!asset) return state; const index = (state.assets || []).findIndex((a) => a.id === asset.id); if (index >= 0) state.assets[index] = asset; else (state.assets ||= []).unshift(asset); } + if (event.type === 'asset.delete' && event.assetId) { + deleteAssetOnly(state, event.assetId); + } if (event.type === 'object.upsert') { if (event.kind === 'dynamic') { const object = unpackDynamic(event.object); @@ -381,7 +560,7 @@ request.onerror = () => resolve(null); }))); db.close(); - return rows.filter(Boolean).map(unpackAsset).filter(Boolean); + return unpackAssets(rows.filter(Boolean)); } async function cacheSnapshot(snapshot) { @@ -398,17 +577,24 @@ root.Phase2Sync = { FORMAT, + FORMAT_V1, SNAPSHOT_FORMAT, ASSET_BUNDLE_FORMAT, + ASSET_BUNDLE_FORMAT_V1, EVENT_LOG_LIMIT, isCompactState, isAssetBundle, rleEncode, rleDecode, + pack6Text, + unpack6Text, + packBitMask, + unpackBitMask, cropPlane, expandPlane, packAsset, unpackAsset, + unpackAssets, compactState, expandState, compactSizeReport, @@ -419,8 +605,10 @@ findMissingAssetIds, makeEvent, createAssetUpsertEvent, + createAssetDeleteEvent, createObjectUpsertEvent, createObjectDeleteEvent, + deleteAssetOnly, applyEvent, cacheAssets, readCachedAssets, diff --git a/server/__pycache__/compression_lab.cpython-313.pyc b/server/__pycache__/compression_lab.cpython-313.pyc index ced65c6cda586629e02f6c7e00531eef1d74f77a..9d230cc2485e8a9929eb507d1056a90a38d5840f 100644 GIT binary patch delta 26 gcmbO)a#@(`GcPX}0}!z7*vRF<&M3KgK6?Wb09uCzcmMzZ delta 106 zcmcaCJYR(CGcPX}0}xE#zLCp=-8WS~H?KrLC9xz?zbL;Xu_QA;FTOm#C|f_XI43bL zMYkZcA~i?1xHLC6KQFaNx6DizrcAdWKPNM}Qa2|*IT5HLF)dYpvMi(I<{b8PCIF>D BB`5#@ diff --git a/server/__pycache__/rotation_worker.cpython-313.pyc b/server/__pycache__/rotation_worker.cpython-313.pyc index bcdbb7c700df9307a25433abca86b7a1ad0b6524..40a187ff7163e11fa3311dfa6ee8c7ff20600b35 100644 GIT binary patch delta 28 icmZo(#JGJSBiCnMUM>b8VB4{gON)z9a&seBlN$hXX9sQo delta 104 zcmdnKkg<6YBiCnMUM>b8=-s}NON+}lRX;bcL_a05BvHR8za+6FGe0lBJijPgKeIR| zF)u~8AhRMhN4K~%H#a{owMe(jOc$n1w;(?!Gr3YXCqFq6s3I{fRe!T7SCbn6WB?^f diff --git a/styles.css b/styles.css index f8c9b9a..959cb18 100644 --- a/styles.css +++ b/styles.css @@ -1015,3 +1015,447 @@ body, button, input, select, textarea { font-size: 15px; } .likedCodexItem canvas { width:28px; height:28px; image-rendering:pixelated; } .hiddenReasonText { color:#6f2f3b; font-weight:700; } .exhibitionNote { padding:8px 10px; background:#fff3d9; border:2px solid rgba(36,48,68,.2); margin-bottom:8px; font-size:14px; line-height:1.45; } + + +/* compact account bar / remove right-side HUD */ +.toolHud { display: none !important; } +.topHud { min-height: 50px; padding: 8px 12px; gap: 10px; align-items: center; } +.authorCard { + width: auto !important; + min-width: 0 !important; + max-width: min(520px, 48vw); + display: flex !important; + align-items: center; + gap: 8px; + padding: 5px 7px !important; + white-space: nowrap; +} +.authorCard span { display:inline !important; margin:0; font-size:11px !important; } +.authorCard input { margin-top:0 !important; width:132px; min-height:28px; } +.authorCard small { display:inline !important; margin-top:0 !important; line-height:1.15 !important; font-size:11px !important; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.miniButton { margin-top:0 !important; padding:5px 7px !important; font-size:11px !important; white-space:nowrap; } +.clockCard { width: 190px; padding: 6px 8px; } +.subline { white-space: nowrap; } +.advancedRow { grid-template-columns: repeat(5, max-content) 1fr !important; } +@media (max-width: 900px) { + .topHud { right: 76px; align-items:flex-start; } + .authorCard { max-width: calc(100vw - 110px); flex-wrap: wrap; white-space: normal; } + .authorCard small { max-width: 100%; } +} + +/* 2026 polish pass: make the island the visual default, keep creation and collection one click away. */ +.islandTopBar { + top: 12px; + left: 50%; + right: auto; + width: min(560px, calc(100vw - 24px)); + min-height: 0 !important; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px !important; + background: rgba(255, 253, 245, .78) !important; + border: 1px solid rgba(36, 48, 68, .24) !important; + box-shadow: 0 6px 18px rgba(22, 31, 46, .12) !important; + backdrop-filter: blur(10px); + pointer-events: auto; +} +.brandBlock { min-width: 0; } +.islandTopBar .logo { font-size: 18px; line-height: 1; white-space: nowrap; } +.islandTopBar .subline { + margin-top: 2px; + max-width: 300px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + opacity: .72; +} +.topActions { display: flex; gap: 6px; align-items: center; } +.topButton { + padding: 7px 10px !important; + border-width: 1px !important; + box-shadow: none !important; + background: rgba(255, 253, 245, .86) !important; + font-size: 12px !important; +} +.topButton.primary { background: var(--mint) !important; } +.utilityControl { display: none !important; } +.edgeAdd { display: none !important; } +.devOnly { display: none !important; } + +/* Beginner-first editor: show only Pencil/Erase/Fill/Pick + Undo/Redo until Advanced is enabled. */ +.advancedTool { display: none !important; } +.studioDrawer.advancedTools .advancedTool { display: inline-flex !important; } +.studioDrawer.advancedTools .toolRow .advancedTool { display: block !important; } +.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorHistoryRow { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; } +#editHint { opacity: .78; } +#editHint::after { content: " Advanced reveals line, rectangle, selection, flip, light, depth, and particles."; } + +/* Let the island breathe under the lighter navigation. */ +#worldCanvas { background: #8dd7ff; } +.selectionBubble { + border-width: 2px !important; + box-shadow: 0 8px 20px rgba(22,31,46,.16) !important; + background: rgba(255,253,245,.94) !important; +} +@media (max-width: 720px) { + .islandTopBar { width: calc(100vw - 16px); top: 8px; } + .islandTopBar .subline { display: none; } + .topButton { padding: 7px 8px !important; } +} + +/* v8 account, editor, and placement refinements */ +.islandClickHint { + position: fixed; + left: 14px; + bottom: 14px; + z-index: 24; + padding: 6px 8px; + border: 1px solid rgba(36,48,68,.22); + background: rgba(255,253,245,.72); + box-shadow: 0 5px 16px rgba(22,31,46,.10); + backdrop-filter: blur(8px); + font-size: 11px; + color: rgba(36,48,68,.78); + pointer-events: none; +} +.islandTopBar { + width: min(960px, calc(100vw - 24px)) !important; +} +.accountCard.utilityControl { + display: flex !important; + align-items: center; + gap: 7px; + max-width: 430px !important; + padding: 5px 7px !important; + border: 1px solid rgba(36,48,68,.2) !important; + background: rgba(255,253,245,.68) !important; + box-shadow: none !important; +} +.accountFields { + display: grid; + grid-template-columns: 96px 112px 112px; + gap: 5px; + align-items: end; +} +.accountField { + display: grid; + gap: 2px; + font-size: 9px !important; + line-height: 1; + color: rgba(36,48,68,.72); +} +.accountField input { + width: 100% !important; + min-height: 24px !important; + padding: 3px 5px !important; + font-size: 10px !important; + border-width: 1px !important; + background: rgba(255,255,255,.72); +} +.accountField input[readonly] { opacity: .72; } +.accountCard small { + max-width: 118px !important; + white-space: normal !important; + font-size: 9px !important; + line-height: 1.15 !important; + opacity: .78; +} +.accountCard .miniButton { + font-size: 9px !important; + padding: 4px 5px !important; + border-width: 1px !important; + box-shadow: none !important; +} +.clockCard.utilityControl { display: none !important; } +.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +.advancedToolGroup { + display: grid; + gap: 8px; + margin: 2px 0 8px; +} +.advancedToolGroup[hidden] { display: none !important; } +.advancedDrawRow { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; } +.advancedActionRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .advancedTool { display: block !important; } +.toolBrush { background: #e8fff0 !important; } +.toolErase { background: #ffe9e2 !important; } +.toolFill { background: #e7f0ff !important; } +.toolPick { background: #fff0d4 !important; } +.toolUndo, .toolRedo { background: #f2ecff !important; } +.toolLine { background: #e9f7ff !important; } +.toolRect { background: #edf9e3 !important; } +.toolSelect { background: #ffe8f3 !important; } +.toolDoor { background: #f5ead7 !important; } +.toolOutline { background: #fff6d8 !important; } +.toolFlipH, .toolFlipV { background: #eaf2ff !important; } +.toolClearSel { background: #f7e9ff !important; } +.toolAdvanced { background: #f5f1e7 !important; } +.toolLight { background: #fff0a8 !important; } +.toolDepth { background: #dfeeff !important; } +.toolParticle { background: #e8fffb !important; } +.tool.active, button.active { + outline: 2px solid rgba(36,48,68,.55); + outline-offset: -4px; + filter: saturate(1.08); +} +.particleControl { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 7px; + border: 2px solid var(--line); + background: #e8fffb; + box-shadow: 3px 3px 0 rgba(36,48,68,.14); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} +.particleControl[hidden] { display: none !important; } +.particleControl select { + min-height: 26px; + padding: 2px 5px; + font-size: 11px; +} +.finishActions { grid-template-columns: 1.4fr 1fr !important; } +.placementPreviewBar span::after { content: " Left-click chooses a tile; right-click cancels selection."; } +@media (max-width: 980px) { + .islandTopBar { width: calc(100vw - 16px) !important; flex-wrap: wrap; justify-content: center; } + .accountCard.utilityControl { order: 3; max-width: 100% !important; } + .accountFields { grid-template-columns: repeat(3, minmax(72px, 1fr)); } + .accountCard small { max-width: 100% !important; } +} +@media (max-width: 560px) { + .accountFields { grid-template-columns: 1fr; } + .finishActions { grid-template-columns: 1fr !important; } + .advancedDrawRow, .advancedActionRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +} + +/* v9 menu and particle-range refinements */ +.cuteMenuButton { + flex: 0 0 auto; + width: 38px; + height: 38px; + padding: 0 !important; + display: grid; + place-items: center; + border: 2px solid rgba(36,48,68,.72) !important; + border-radius: 14px !important; + background: linear-gradient(180deg, #ffe3f0, #ffc3dd) !important; + box-shadow: 3px 3px 0 rgba(36,48,68,.18) !important; + font-size: 19px !important; + line-height: 1 !important; + cursor: pointer; +} +.cuteMenuButton:hover { transform: translateY(-1px); filter: saturate(1.08); } +.cuteMenuButton:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(36,48,68,.18) !important; } +.islandTopBar .brandBlock { margin-right: auto; } +.particleControlGroup { + display: grid !important; + grid-template-columns: minmax(92px, 120px) auto; + align-items: end; + gap: 6px 8px; + max-width: min(100%, 360px); + text-transform: none; +} +.particleControlGroup label { font-size: 10px; font-weight: 900; color: rgba(36,48,68,.76); } +.particleRangeButtons { + display: grid; + grid-template-columns: repeat(2, minmax(74px, 1fr)); + gap: 5px; +} +.particleRangeButtons .tool { padding: 6px 7px !important; font-size: 11px !important; } +#particleRangeStatus { + grid-column: 1 / -1; + font-size: 10px; + color: rgba(36,48,68,.72); + line-height: 1.25; +} +@media (max-width: 560px) { + .particleControlGroup { grid-template-columns: 1fr; } + .particleRangeButtons { grid-template-columns: 1fr 1fr; } +} + +/* v10 bottom DRAW access and remix bottom sheet */ +.drawDockButton { + position: fixed; + z-index: 28; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + min-width: 210px; + min-height: 62px; + padding: 12px 28px !important; + border: 4px solid var(--line) !important; + border-radius: 22px !important; + background: linear-gradient(180deg, #ffe18f, #ff9dbf 62%, #ff7aa8) !important; + color: #253044 !important; + box-shadow: 0 8px 0 rgba(36,48,68,.24), 0 14px 26px rgba(22,31,46,.18) !important; + font-size: 24px !important; + letter-spacing: .06em; + line-height: 1 !important; + cursor: pointer; +} +.drawDockButton:hover { filter: saturate(1.08) brightness(1.02); } +.drawDockButton:active { transform: translate(-50%, 4px); box-shadow: 0 4px 0 rgba(36,48,68,.24), 0 8px 18px rgba(22,31,46,.16) !important; } +.drawDockButton[hidden] { display: none !important; } +.islandTopBar .cuteMenuButton { display: none !important; } +.islandTopBar .brandBlock { margin-right: 0 !important; } +.islandClickHint { bottom: 96px !important; } + +.selectionBubble { + position: absolute !important; + left: 0 !important; + top: 0 !important; + z-index: 36 !important; + min-width: 138px !important; + max-width: 224px !important; + padding: 8px 9px !important; + display: block !important; + text-align: center !important; + border: 3px solid var(--line) !important; + border-radius: 14px !important; + background: rgba(255,253,245,.98) !important; + box-shadow: 0 6px 0 rgba(36,48,68,.18), 0 10px 20px rgba(22,31,46,.15) !important; +} +.selectionBubble[hidden] { display: none !important; } +.selectionBubble::after { + display: block !important; + content: '' !important; + position: absolute; + left: 50%; + bottom: -10px; + width: 14px; + height: 14px; + background: rgba(255,253,245,.98); + border-right: 3px solid var(--line); + border-bottom: 3px solid var(--line); + transform: translateX(-50%) rotate(45deg); +} +.bubbleName { font-size: 13px !important; } +.bubbleAuthor, .bubbleRemixFrom, .bubbleRemixCount { font-size: 10px !important; margin-top: 2px !important; } +.bubbleVotes { margin-top: 6px !important; grid-template-columns: 1fr auto 1fr !important; justify-content: center; } +.bubbleActions { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + gap: 5px !important; + margin-top: 6px !important; +} +.bubbleActions button { min-height: 32px; font-size: 10px !important; } +.bubbleRemixPrimary { + background: linear-gradient(180deg, #ffb3d1, #ff7daf) !important; + border-width: 3px !important; +} +.bubbleMenuButton { + margin-top: 6px; + min-width: 64px; + min-height: 28px; + padding: 5px 8px !important; + border-radius: 12px !important; + background: #fff2d7 !important; + font-size: 10px !important; +} +.bubbleMenuActions { + display: flex; + justify-content: center; + gap: 6px; + padding-top: 4px; + margin-top: 4px; + border-top: 1px dashed rgba(36,48,68,.25); +} +.bubbleMenuActions[hidden] { display: none !important; } +.bubbleMenuActions button { font-size: 10px !important; padding: 5px 8px !important; } +@media (max-width: 760px) { + .drawDockButton { min-width: 176px; min-height: 56px; font-size: 20px !important; bottom: 16px; } + .islandClickHint { display: none; } +} + + +/* v15 analog day-night clock */ +.analogClock { + --clock-rotate: 0deg; + position: fixed; + top: clamp(104px, 14vh, 158px); + right: 18px; + z-index: 28; + width: 82px; + height: 100px; + display: grid; + place-items: center; + gap: 4px; + pointer-events: none; +} +.analogClockDial { + position: relative; + width: 72px; + height: 72px; + border: 4px solid var(--line); + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, rgba(255,255,245,.42) 0 6%, rgba(255,255,245,0) 7%), + conic-gradient(from -90deg, + #142147 0deg 54deg, + #5468aa 70deg, + #f5b067 96deg, + #8bd8ff 128deg 232deg, + #ff9d4f 266deg, + #c84f2e 292deg, + #1b2a58 326deg 360deg); + box-shadow: 0 5px 0 rgba(36,48,68,.20), 0 8px 18px rgba(18,28,44,.18); + image-rendering: pixelated; +} +.analogClockDial::after { + content: ''; + position: absolute; + inset: 8px; + border: 1px dashed rgba(36,48,68,.25); + border-radius: 50%; +} +.analogClockHand { + position: absolute; + left: 50%; + top: 50%; + width: 4px; + height: 28px; + transform-origin: 50% 100%; + transform: translate(-50%, -100%) rotate(var(--clock-rotate)); + background: #243044; + border-radius: 6px 6px 2px 2px; + box-shadow: 1px 0 0 rgba(255,255,255,.45); +} +.analogClockHand::after { + content: ''; + position: absolute; + left: 50%; + bottom: -5px; + width: 10px; + height: 10px; + transform: translateX(-50%); + border: 3px solid var(--line); + background: #fff7e8; + border-radius: 50%; +} +.analogClockLabel { + min-width: 70px; + padding: 2px 7px; + border: 2px solid var(--line); + border-radius: 999px; + background: rgba(255,248,232,.9); + box-shadow: 0 3px 0 rgba(36,48,68,.16); + text-align: center; + font-size: 10px; + font-weight: 950; +} +.analogPhaseBar { display: none !important; } +@media (max-width: 760px) { + .analogClock { top: 82px; right: 10px; transform: scale(.86); transform-origin: top right; } +}