diff --git a/app.js b/app.js index aa0b127..0d5dedb 100644 --- a/app.js +++ b/app.js @@ -32,6 +32,7 @@ const Lighting = MODULES.Lighting || null; const ModuleLoader = MODULES.ModuleLoader || null; const EDITOR_HISTORY_LIMIT = 80; + const MAX_EDITOR_DIMENSION = 64; const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 }; const editorActions = () => MODULES.EditorActions || null; @@ -42,6 +43,44 @@ const mod = (n, m) => ((n % m) + m) % m; const lerp = (a, b, t) => a + (b - a) * t; + + function clampDimension(value, fallback = 8) { + return clampInt ? clampInt(value, 1, MAX_EDITOR_DIMENSION, fallback) : Math.max(1, Math.min(MAX_EDITOR_DIMENSION, Math.round(Number(value) || fallback))); + } + + function rectArea(width, height = width) { + return Math.max(1, Math.floor(Number(width) || 1)) * Math.max(1, Math.floor(Number(height) || width || 1)); + } + + function lightBudgetForArea(width, height = width) { + return Math.max(1, Math.ceil(rectArea(width, height) / 100)); + } + + function assetWidth(asset) { + return clampInt(asset?.width ?? asset?.w ?? asset?.size, 1, MAX_EDITOR_DIMENSION, 16); + } + + function assetHeight(asset) { + return clampInt(asset?.height ?? asset?.ht ?? asset?.size, 1, MAX_EDITOR_DIMENSION, assetWidth(asset)); + } + + function assetMaxSize(asset) { + return Math.max(assetWidth(asset), assetHeight(asset)); + } + + function editorCellSize() { + return Math.max(1, Math.floor(Math.min(els.paintCanvas.width / Math.max(1, editorWidth), els.paintCanvas.height / Math.max(1, editorHeight)))); + } + + function updateDimensionInputs() { + if (els.assetWidth) els.assetWidth.value = String(editorWidth); + if (els.assetHeight) els.assetHeight.value = String(editorHeight); + if (els.assetSize) { + const preset = editorWidth === editorHeight && [8, 16, 32, 64].includes(editorWidth) ? editorWidth : ''; + els.assetSize.value = preset ? String(preset) : String(Math.max(8, Math.min(64, editorSize))); + } + } + function defaultVisualSettings() { return { enableLights: true, enableParticles: true, enableDayNight: true, localDisplayLimit: PHASE5_GUARDRAILS.defaultDisplayLimit }; } @@ -77,7 +116,7 @@ } function roleToCategory(role) { - return role === 'human' || role === 'animal' ? 'dynamic' : 'static'; + return role === 'human' || role === 'animal' || role === 'bird' ? 'dynamic' : 'static'; } function roleToSubtype(role) { @@ -86,7 +125,7 @@ function subtypeToRole(asset) { const subtype = asset?.subtype; - if (['human', 'animal', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype; + if (['human', 'animal', 'bird', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype; if (subtype === 'water') return 'other'; if (asset?.category === 'dynamic') return 'animal'; return 'other'; @@ -94,7 +133,7 @@ const els = { canvas: $('worldCanvas'), - openEditor: $('openEditor'), + openEditor: $('openEditor'), drawQuotaBadge: $('drawQuotaBadge'), finishQuotaBadge: $('finishQuotaBadge'), openCreate: $('openCreate'), openCollection: $('openCollection'), openMenu: $('openMenu'), @@ -119,7 +158,7 @@ drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'), assetName: $('assetName'), - assetSize: $('assetSize'), + assetSize: $('assetSize'), assetWidth: $('assetWidth'), assetHeight: $('assetHeight'), assetCategory: $('assetCategory'), staticKindWrap: $('staticKindWrap'), dynamicKindWrap: $('dynamicKindWrap'), @@ -149,7 +188,6 @@ let world = makeWorld(); let terrainCache = buildTerrainCache(world); - let terrainFrontCache = buildTerrainFrontCache(world); let state = loadState(); let selectedAssetId = state.assets[0]?.id ?? null; let mode = 'inspect'; @@ -158,6 +196,7 @@ down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0, dragging: false, downTime: 0, button: 0 }; + let cursorScreen = { x: 0, y: 0, active: false }; let hoverTile = null; let dynamicRuntime = []; let spriteCache = new Map(); @@ -166,6 +205,8 @@ let lastClockSecond = -1; let toastTimer = null; + let editorWidth = 8; + let editorHeight = 8; let editorSize = 8; let editorPixels = blankPixels(8); let editorLeftPixels = blankPixels(8); @@ -195,10 +236,14 @@ let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; let shadowCanvasCache = new WeakMap(); let shipReflectionCanvasCache = new WeakMap(); + let shadowMaskCanvas = null; + let shadowMaskCtx = null; + let activeShadowCtx = null; let animationFrameId = 0; let dynamicLogicRemainder = 0; let worldIndex = makeEmptyWorldIndex(); let selectedObject = null; + let cameraFollowSelected = false; let pendingReport = null; let libraryFilter = 'all'; let renderPhase = null; @@ -323,20 +368,14 @@ document.addEventListener('contextmenu', onDocumentContextMenu); document.addEventListener('visibilitychange', onVisibilityChange); - els.assetSize.addEventListener('change', () => { - const oldSize = editorSize; - const nextSize = Number(els.assetSize.value); - commitEditorMutation(() => { - const resizedRight = resizePixels(editorPixels, oldSize, nextSize); - const resizedDepth = resizeDepthPixels(depthPixels, oldSize, nextSize); - setupEditor(nextSize, resizedRight, null, resizedDepth); - doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; - lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); - particlePixels = particlePixels.filter((p) => p.x < nextSize && p.y < nextSize); - resetEditorView(); - return true; + els.assetSize?.addEventListener('change', () => { + const nextSize = clampDimension(els.assetSize.value, editorSize); + resizeEditorCanvas(nextSize, nextSize); + }); + [els.assetWidth, els.assetHeight].filter(Boolean).forEach((input) => { + input.addEventListener('change', () => { + resizeEditorCanvas(clampDimension(els.assetWidth?.value, editorWidth), clampDimension(els.assetHeight?.value, editorHeight)); }); - updateSettingsSummary(); }); els.assetCategory.addEventListener('change', refreshCategoryUI); @@ -388,7 +427,7 @@ toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); - toggleHidden(els.particleDirectionWrap, !advancedDraw); + toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced.classList.toggle('active', advancedDraw); els.drawer?.classList.toggle('advancedTools', advancedDraw); @@ -400,7 +439,7 @@ if (!confirm('Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.')) return; commitEditorMutation(() => { editorPixels = blankPixels(editorSize); - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; particlePixels = []; @@ -426,6 +465,7 @@ els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); + els.bubbleEdit?.addEventListener('click', () => editSelectedOriginal()); els.bubbleTeleport?.addEventListener('click', () => teleportToRemixSource()); els.bubbleMenu?.addEventListener('click', () => toggleBubbleMenu()); els.bubbleReport?.addEventListener('click', () => openReportDialog()); @@ -474,6 +514,7 @@ els.canvas.style.width = `${cw}px`; els.canvas.style.height = `${ch}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (!cursorScreen.active) cursorScreen = { x: cw / 2, y: ch / 2, active: false }; } function resetView(announce) { @@ -490,6 +531,7 @@ if (open) ensureEditorHelpers(); if (!open) { selectedObject = null; + cameraFollowSelected = false; updateSelectionBubble(performance.now()); } } @@ -541,7 +583,7 @@ toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); - toggleHidden(els.particleDirectionWrap, !advancedDraw); + toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); @@ -564,6 +606,7 @@ const textMap = { human: 'Humans move by target direction. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.', animal: 'Animals move by target direction. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.', + bird: 'Birds fly over terrain and seek nature. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.', nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.', building: 'Buildings attract humans. Use Door to mark the entrance.', ship: 'Ships are static water objects. They float and emit ring ripples.', @@ -577,14 +620,15 @@ const role = currentRole(); if (roleToCategory(role) === 'static') { const lightCount = lightPixels.length; - const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light'; + const maxLights = lightBudgetForArea(editorWidth, editorHeight); + const lightText = lightCount ? `${lightCount}/${maxLights} lamp cell${lightCount === 1 ? '' : 's'}` : `no light (${maxLights} max)`; const particleText = particlePixels.length ? `particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : 'no particles'; const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; - els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText} / ${particleText}${doorText}.`; + els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}×${editorHeight} canvas / 2× static pixels / ${lightText} / ${particleText}${doorText}.`; } else { const particleText = particlePixels.length ? ` / particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : ''; const sideText = editingSide === 'left' ? 'canvas marked ◀ Left; Save mirrors it into canonical ▶ Right' : 'canvas marked canonical ▶ Right'; - els.settingsSummary.textContent = `${cap(role)} / ${sideText}${particleText}.`; + els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}×${editorHeight} canvas / ${sideText}${particleText}.`; } } @@ -608,6 +652,7 @@ els.editHint.textContent = hints[tool] || hints.brush; [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); + toggleHidden(els.particleDirectionWrap, !advancedDraw || tool !== 'particle'); updateParticleUI(); } @@ -640,6 +685,8 @@ function onWorldPointerDown(event) { event.preventDefault(); + const downPos = getCanvasPoint(event); + cursorScreen = { x: downPos.x, y: downPos.y, active: true }; if (event.button === 2) { cancelPlacementPreview(false); clearWorldSelection(true); @@ -660,11 +707,11 @@ downTime: performance.now(), button: event.button || 0 }; - els.canvas.classList.add('dragging'); } function onWorldPointerMove(event) { const pos = getCanvasPoint(event); + cursorScreen = { x: pos.x, y: pos.y, active: true }; hoverTile = screenToTile(pos.x, pos.y); updateTileInfo(); @@ -672,8 +719,12 @@ const dx = event.clientX - pointer.lastX; const dy = event.clientY - pointer.lastY; const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); - if (total > 3) pointer.dragging = true; + if (total > 8) { + pointer.dragging = true; + els.canvas.classList.add('dragging'); + } if (pointer.dragging) { + cameraFollowSelected = false; view.x += dx; view.y += dy; } @@ -688,6 +739,7 @@ const elapsed = performance.now() - pointer.downTime; const wasClick = total < 6 && elapsed < 600; const pos = getCanvasPoint(event); + cursorScreen = { x: pos.x, y: pos.y, active: true }; const tile = screenToTile(pos.x, pos.y); if (wasClick) { const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now()); @@ -708,6 +760,7 @@ function onWorldWheel(event) { event.preventDefault(); + cameraFollowSelected = false; const pos = getCanvasPoint(event); const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13; zoomAt(pos.x, pos.y, view.zoom * factor); @@ -765,6 +818,12 @@ }; } + function worldToTileFloat(worldX, worldY) { + const a = (worldX - ORIGIN_X) / (TILE_W / 2); + const b = (worldY - ORIGIN_Y) / (TILE_H / 2); + return { x: (a + b) / 2, y: (b - a) / 2 }; + } + function tileKey(x, y) { return `${x},${y}`; } @@ -952,6 +1011,7 @@ } if (quota.remaining <= 0) { toast(`Publish limit reached: ${quota.limit} public placements per hour.`); + updatePublishQuotaUI(); return false; } return true; @@ -965,6 +1025,92 @@ state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; state.publishLog.push({ at: now, author: currentVoterKey(), kind, objectId: object.id, assetId: object.assetId, action }); state.publishLog = state.publishLog.slice(-300); + updatePublishQuotaUI(); + } + + function isSharedWorld() { + return state.worldMode === 'shared'; + } + + function currentAccountId() { + return String(state.account?.id || '').trim(); + } + + function normalizeOwnerAccountId(value, fallback = '') { + return String(value || fallback || '').trim(); + } + + function ensureWorldProtectionState(target = state) { + target.worldMode = target.worldMode === 'shared' ? 'shared' : 'local'; + target.serverSync = { + lastServerEventId: target.serverSync?.lastServerEventId || null, + pendingCommands: Array.isArray(target.serverSync?.pendingCommands) ? target.serverSync.pendingCommands.slice(-300) : [] + }; + target.tombstones = { + assets: target.tombstones?.assets && typeof target.tombstones.assets === 'object' ? target.tombstones.assets : {}, + objects: target.tombstones?.objects && typeof target.tombstones.objects === 'object' ? target.tombstones.objects : {} + }; + return target; + } + + function isAssetTombstoned(assetId, version = 0) { + const tombstone = state.tombstones?.assets?.[assetId]; + return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0)); + } + + function isObjectTombstoned(objectId, version = 0) { + const tombstone = state.tombstones?.objects?.[objectId]; + return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0)); + } + + function getObjectOwner(kind, object) { + if (!object) return ''; + return normalizeOwnerAccountId(object.ownerAccountId, findAsset(object.assetId)?.ownerAccountId || ''); + } + + function canCurrentAccountModifyObject(kind, object, showToast = true) { + if (!isSharedWorld()) return true; + const actor = currentAccountId(); + if (!actor) { + if (showToast) toast('Shared worlds require an account before changing island objects.'); + return false; + } + const owner = getObjectOwner(kind, object); + if (owner && owner === actor) return true; + if (showToast) toast('Only the owner can move or delete this island object.'); + return false; + } + + function canCurrentAccountDeleteAsset(asset, showToast = true) { + if (!isSharedWorld()) return true; + const actor = currentAccountId(); + if (!actor) { + if (showToast) toast('Shared worlds require an account before deleting assets.'); + return false; + } + const owner = normalizeOwnerAccountId(asset?.ownerAccountId); + if (owner && owner === actor) return true; + if (showToast) toast('Only the owner can delete this asset in a shared world.'); + return false; + } + + function makeSharedCommand(type, payload = {}) { + return { + id: `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + type, + actorAccountId: currentAccountId(), + createdAt: Date.now(), + ...payload + }; + } + + function queueSharedCommand(command) { + if (!command) return; + ensureWorldProtectionState(); + state.serverSync.pendingCommands.push(command); + state.serverSync.pendingCommands = state.serverSync.pendingCommands.slice(-300); + saveState(); + toast('Change queued for server validation.'); } @@ -1030,8 +1176,8 @@ const pos = tileToWorld(x, y); if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y); const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const w = asset.size * scale + 96; - const h = asset.size * scale + 128; + const w = assetWidth(asset) * scale + 96; + const h = assetHeight(asset) * scale + 128; return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom; } @@ -1070,12 +1216,14 @@ 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 aw = assetWidth(asset); + const ah = assetHeight(asset); + if (Math.max(aw, ah) <= 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; + if (px < 0 || py < 0 || px >= aw || py >= ah) return false; const pixels = getAssetPixels(asset, info.side || 'right'); - return Boolean(pixels[py * asset.size + px]); + return Boolean(pixels[py * aw + px]); } function updateTileInfo() { @@ -1126,10 +1274,12 @@ function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) { selectedObject = { kind, id: objectId, assetId, selectedAt }; + cameraFollowSelected = true; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; selectedAssetId = assetId; updateSelectedLabel(); renderLibrary(); + render(selectedAt); updateSelectionBubble(selectedAt); } @@ -1195,6 +1345,44 @@ return; } + if (isSharedWorld()) { + ensureLocalAccount(existingObject ? 'republish' : 'publish'); + const actor = currentAccountId(); + if (!normalizeOwnerAccountId(asset.ownerAccountId)) asset.ownerAccountId = actor; + if (normalizeOwnerAccountId(asset.ownerAccountId) !== actor) { + toast('Shared worlds only let you publish placements from assets you own. Use Remix to make your own version.'); + return; + } + if (existingObject && !canCurrentAccountModifyObject(kind, existingObject)) return; + const nextObject = asset.category === 'static' + ? { + ...(existingObject || {}), + id: existingObject?.id || uid(), + assetId: asset.id, + ownerAccountId: actor, + x, + y, + placedAt: Date.now(), + publishedAt: existingObject?.publishedAt || Date.now(), + status: 'active', + version: Number(existingObject?.version || 0) + 1 + } + : { + ...(existingObject || {}), + id: existingObject?.id || uid(), + assetId: asset.id, + ownerAccountId: actor, + homeX: x, + homeY: y, + createdAt: Date.now(), + publishedAt: existingObject?.publishedAt || Date.now(), + status: 'active', + version: Number(existingObject?.version || 0) + 1 + }; + queueSharedCommand(makeSharedCommand(existingObject ? 'object.move' : 'object.publish', { kind, object: nextObject })); + return; + } + let object = null; if (asset.category === 'static') { const existing = state.placed.find((p) => p.assetId === asset.id); @@ -1208,7 +1396,7 @@ selectWorldObject('static', existing.id, asset.id, performance.now()); toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { - const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; + const placed = { id: uid(), assetId: asset.id, ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()), x, y, placedAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.placed.push(placed); recordObjectPublish('static', placed, 'publish'); object = placed; @@ -1226,7 +1414,7 @@ object = existing; toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { - const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; + const summon = { id: uid(), assetId: asset.id, ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()), homeX: x, homeY: y, createdAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.dynamicSummons.push(summon); recordObjectPublish('dynamic', summon, 'publish'); object = summon; @@ -1266,6 +1454,11 @@ const staticObjects = getPlacedAtTile(x, y); const staticTarget = staticObjects.at(-1); if (staticTarget) { + if (isSharedWorld()) { + if (!canCurrentAccountModifyObject('static', staticTarget)) return; + queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId: staticTarget.id })); + return; + } const index = state.placed.findIndex((p) => p.id === staticTarget.id); if (index >= 0) { const item = state.placed[index]; @@ -1282,6 +1475,11 @@ const dynamicObjects = getDynamicHomesAtTile(x, y); const dynamicTarget = dynamicObjects.at(-1); if (dynamicTarget) { + if (isSharedWorld()) { + if (!canCurrentAccountModifyObject('dynamic', dynamicTarget)) return; + queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId: dynamicTarget.id })); + return; + } const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id); if (index >= 0) { const item = state.dynamicSummons[index]; @@ -1305,6 +1503,11 @@ if (kind === 'static') { const index = state.placed.findIndex((p) => p.id === objectId); if (index >= 0) { + if (isSharedWorld()) { + if (!canCurrentAccountModifyObject('static', state.placed[index])) return; + queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId })); + return; + } const asset = findAsset(state.placed[index].assetId); state.placed.splice(index, 1); rebuildWorldIndex(); @@ -1319,6 +1522,11 @@ if (kind === 'dynamic') { const index = state.dynamicSummons.findIndex((p) => p.id === objectId); if (index >= 0) { + if (isSharedWorld()) { + if (!canCurrentAccountModifyObject('dynamic', state.dynamicSummons[index])) return; + queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId })); + return; + } const asset = findAsset(state.dynamicSummons[index].assetId); state.dynamicSummons.splice(index, 1); rebuildWorldIndex(); @@ -1476,7 +1684,7 @@ const local = clientToPaintLocal(clientX, clientY); const x = Math.floor(local.x); const y = Math.floor(local.y); - if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return; + if (x < 0 || y < 0 || x >= editorWidth || y >= editorHeight) return; const key = `${x},${y},${paintTool},${button},${shiftKey}`; if (key === lastPaintedKey && paintTool !== 'fill') return; lastPaintedKey = key; @@ -1494,15 +1702,14 @@ } if (paintTool === 'fill') { - const displayPixels = getDisplayEditorPixels(); + const displayPixels = compactPixelsFromStride(editorPixels, editorSize, editorWidth, editorHeight); const fillColor = shiftKey ? null : selectedColorCode; const actions = editorActions(); const nextDisplay = actions?.floodFill - ? actions.floodFill(displayPixels, editorSize, x, y, fillColor) - : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); + ? actions.floodFill(displayPixels, editorWidth, x, y, fillColor, editorHeight) + : fallbackFloodFill(displayPixels, editorWidth, x, y, fillColor, editorHeight); if (nextDisplay.changed) { - editorPixels = nextDisplay.pixels; - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + setDisplayEditorPixels(nextDisplay.pixels); if (fillColor === null && Array.isArray(nextDisplay.cells)) { for (const cell of nextDisplay.cells) { const src = displayCellToCanonical(cell.x, cell.y); @@ -1561,7 +1768,7 @@ } if (changed) { - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); markEditorChanged(); drawEditor(); } @@ -1582,7 +1789,7 @@ const scaleY = els.paintCanvas.height / rect.height; const sx = (clientX - rect.left) * scaleX; const sy = (clientY - rect.top) * scaleY; - const cell = els.paintCanvas.width / editorSize; + const cell = editorCellSize(); return { x: (sx - editorView.x) / editorView.zoom / cell, y: (sy - editorView.y) / editorView.zoom / cell @@ -1592,6 +1799,8 @@ function snapshotEditorState() { return { size: editorSize, + width: editorWidth, + height: editorHeight, rightPixels: [...editorPixels], depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), @@ -1605,18 +1814,20 @@ } function restoreEditorState(snapshot) { - editorSize = snapshot.size || editorSize; + editorWidth = clampDimension(snapshot.width ?? snapshot.size, editorWidth); + editorHeight = clampDimension(snapshot.height ?? snapshot.size, editorHeight); + editorSize = Math.max(editorWidth, editorHeight); editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize); - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; particlePixels = Array.isArray(snapshot.particlePixels) ? snapshot.particlePixels.map((p) => ({ ...p })) : []; particleConfig = normalizeParticleConfig(snapshot.particleConfig || (particlePixels.length ? { enabled: true, c: particlePixels[0].c, dir: particlePixels[0].dir || 'up' } : particleConfig)); - doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; + doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorWidth / 2), y: editorHeight - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null; - els.assetSize.value = String(editorSize); + updateDimensionInputs(); updateSideButtons(); renderPalette(); drawEditor(); @@ -1655,7 +1866,7 @@ editorHistory.push(before); if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); editorFuture = []; - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); drawEditor(); updateSettingsSummary(); updateEditorHistoryButtons(); @@ -1714,17 +1925,19 @@ } } - function fallbackFloodFill(sourcePixels, size, x, y, colorCode) { + function fallbackFloodFill(sourcePixels, width, x, y, colorCode, height = width) { + const w = Math.max(1, Math.round(Number(width) || 1)); + const h = Math.max(1, Math.round(Number(height) || w)); const pixels = [...sourcePixels]; - const target = pixels[y * size + x] || null; + const target = pixels[y * w + x] || null; const replacement = colorCode || null; if (target === replacement) return { pixels, changed: false, count: 0, cells: [] }; const stack = [[x, y]]; const cells = []; while (stack.length) { const [cx, cy] = stack.pop(); - if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue; - const index = cy * size + cx; + if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; + const index = cy * w + cx; if ((pixels[index] || null) !== target) continue; pixels[index] = replacement; cells.push({ x: cx, y: cy }); @@ -1831,7 +2044,7 @@ } if (selectionGesture?.mode === 'move') { selectionGesture = null; - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); updateEditorSelectionButtons(); return; } @@ -1841,15 +2054,15 @@ const local = clientToPaintLocal(clientX, clientY); const x = Math.floor(local.x); const y = Math.floor(local.y); - if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return null; + if (x < 0 || y < 0 || x >= editorWidth || y >= editorHeight) return null; return { x, y }; } function normalizeSelectionRect(x0, y0, x1, y1) { - const left = clamp(Math.min(x0, x1), 0, editorSize - 1); - const top = clamp(Math.min(y0, y1), 0, editorSize - 1); - const right = clamp(Math.max(x0, x1), 0, editorSize - 1); - const bottom = clamp(Math.max(y0, y1), 0, editorSize - 1); + const left = clamp(Math.min(x0, x1), 0, editorWidth - 1); + const top = clamp(Math.min(y0, y1), 0, editorHeight - 1); + const right = clamp(Math.max(x0, x1), 0, editorWidth - 1); + const bottom = clamp(Math.max(y0, y1), 0, editorHeight - 1); return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 }; } @@ -1860,8 +2073,8 @@ function clampSelectionDelta(selection, dx, dy) { if (!selection) return { dx: 0, dy: 0 }; return { - dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)), - dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h)) + dx: clamp(dx, -selection.x, editorWidth - (selection.x + selection.w)), + dy: clamp(dy, -selection.y, editorHeight - (selection.y + selection.h)) }; } @@ -1870,20 +2083,24 @@ } function setDisplayEditorPixels(pixels) { - editorPixels = normalizePixels(pixels, editorSize); - editorLeftPixels = mirrorPixels(editorPixels, editorSize); + editorPixels = Array.isArray(pixels) && pixels.length === editorWidth * editorHeight && (editorWidth !== editorSize || editorHeight !== editorSize) + ? inflatePixelsToStride(pixels, editorWidth, editorHeight, editorSize) + : normalizePixels(pixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); } function setDisplayDepthPixels(pixels) { - depthPixels = normalizeDepthPixels(pixels, editorSize); + depthPixels = Array.isArray(pixels) && pixels.length === editorWidth * editorHeight && (editorWidth !== editorSize || editorHeight !== editorSize) + ? inflateDepthToStride(pixels, editorWidth, editorHeight, editorSize) + : normalizeDepthPixels(pixels, editorSize); } - function mirrorScalarPixels(values, size) { - const source = Array.isArray(values) ? values : []; - 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; + function mirrorScalarPixels(values, stride, width = stride, height = width) { + const source = normalizeDepthPixels(values, stride); + const out = Array(stride * stride).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + out[y * stride + (width - 1 - x)] = source[y * stride + x] || 0; } } return out; @@ -1927,7 +2144,7 @@ } const nx = shown.x + dx; const ny = shown.y + dy; - if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue; + if (nx < 0 || ny < 0 || nx >= editorWidth || ny >= editorHeight) continue; const canonical = displayCellToCanonical(nx, ny); out.push({ ...point, x: canonical.x, y: canonical.y }); } @@ -1951,12 +2168,12 @@ function shiftEditorContent(dx, dy) { return commitEditorMutation(() => { - const selection = { x: 0, y: 0, w: editorSize, h: editorSize }; + const selection = { x: 0, y: 0, w: editorWidth, h: editorHeight }; const bounded = clampSelectionDelta(selection, dx, dy); if (!bounded.dx && !bounded.dy) return false; applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); editorSelection = null; - doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) }; + doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorWidth - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorHeight - 1) }; return true; }); } @@ -2001,7 +2218,7 @@ if (e2 >= dy) { err += dy; x += sx; } if (e2 <= dx) { err += dx; y += sy; } } - return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize); + return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorWidth && cell.y < editorHeight); } function getRectCells(x0, y0, x1, y1, filled = false) { @@ -2070,6 +2287,7 @@ function updateParticleUI() { particleConfig = normalizeParticleConfig(particleConfig); if (els.particleDirection) els.particleDirection.value = particleConfig.dir; + toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); if (els.toolParticle) els.toolParticle.classList.toggle('active', paintTool === 'particle' || particlePixels.length > 0); if (els.particleRangeStatus) els.particleRangeStatus.textContent = `Particle cells: ${particlePixels.length}. Shift/right-click clears a cell.`; if (els.particleClearRange) els.particleClearRange.disabled = particlePixels.length === 0; @@ -2124,42 +2342,42 @@ function flipEditorHorizontal() { commitEditorMutation(() => { - editorPixels = mirrorPixels(editorPixels, editorSize); - depthPixels = mirrorScalarPixels(depthPixels, editorSize); - lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); - particlePixels = particlePixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); - doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x }; - editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; + editorPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); + depthPixels = mirrorScalarPixels(depthPixels, editorSize, editorWidth, editorHeight); + lightPixels = lightPixels.map((p) => ({ ...p, x: editorWidth - 1 - p.x })); + particlePixels = particlePixels.map((p) => ({ ...p, x: editorWidth - 1 - p.x })); + doorPixel = { ...doorPixel, x: editorWidth - 1 - doorPixel.x }; + editorSelection = editorSelection ? { x: editorWidth - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; return true; }); } function flipEditorVertical() { commitEditorMutation(() => { - editorPixels = flipPixelsVertical(editorPixels, editorSize); - depthPixels = flipScalarPixelsVertical(depthPixels, editorSize); - lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); - particlePixels = particlePixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); - doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y }; - editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; + editorPixels = flipPixelsVertical(editorPixels, editorSize, editorWidth, editorHeight); + depthPixels = flipScalarPixelsVertical(depthPixels, editorSize, editorWidth, editorHeight); + lightPixels = lightPixels.map((p) => ({ ...p, y: editorHeight - 1 - p.y })); + particlePixels = particlePixels.map((p) => ({ ...p, y: editorHeight - 1 - p.y })); + doorPixel = { ...doorPixel, y: editorHeight - 1 - doorPixel.y }; + editorSelection = editorSelection ? { x: editorSelection.x, y: editorHeight - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; return true; }); } - function flipPixelsVertical(pixels, size) { - const source = normalizePixels(pixels, size); - const out = blankPixels(size); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || null; + function flipPixelsVertical(pixels, stride, width = stride, height = width) { + const source = normalizePixels(pixels, stride); + const out = blankPixels(stride); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[(height - 1 - y) * stride + x] = source[y * stride + x] || null; } return out; } - function flipScalarPixelsVertical(values, size) { - const source = normalizeDepthPixels(values, size); - const out = Array(size * size).fill(0); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || 0; + function flipScalarPixelsVertical(values, stride, width = stride, height = width) { + const source = normalizeDepthPixels(values, stride); + const out = Array(stride * stride).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[(height - 1 - y) * stride + x] = source[y * stride + x] || 0; } return out; } @@ -2169,14 +2387,14 @@ const source = [...editorPixels]; const out = [...editorPixels]; let changed = false; - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; x++) { + for (let y = 0; y < editorHeight; y++) { + for (let x = 0; x < editorWidth; x++) { const index = y * editorSize + x; if (source[index]) continue; const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => { const nx = x + dx; const ny = y + dy; - return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source[ny * editorSize + nx]; + return nx >= 0 && ny >= 0 && nx < editorWidth && ny < editorHeight && source[ny * editorSize + nx]; }); if (adjacent) { out[index] = selectedColorCode; changed = true; } } @@ -2188,15 +2406,15 @@ } function exportEditorPng() { - const scale = Math.max(8, Math.floor(512 / editorSize)); + const scale = Math.max(8, Math.floor(512 / Math.max(editorWidth, editorHeight))); const canvas = document.createElement('canvas'); - canvas.width = editorSize * scale; - canvas.height = editorSize * scale; + canvas.width = editorWidth * scale; + canvas.height = editorHeight * scale; const c = canvas.getContext('2d'); c.imageSmoothingEnabled = false; const pixels = getDisplayEditorPixels(); - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; x++) { + for (let y = 0; y < editorHeight; y++) { + for (let x = 0; x < editorWidth; x++) { const code = pixels[y * editorSize + x]; if (!code) continue; c.fillStyle = colorToHex(code); @@ -2205,7 +2423,7 @@ } const link = document.createElement('a'); const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art'; - link.download = `${rawName}-${editorSize}x${editorSize}.png`; + link.download = `${rawName}-${editorWidth}x${editorHeight}.png`; link.href = canvas.toDataURL('image/png'); link.click(); toast('PNG exported.'); @@ -2217,63 +2435,52 @@ if (!file) return; const img = new Image(); img.onload = () => { - const targetSize = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height); + const target = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height); commitEditorMutation(() => { const previousSize = editorSize; const canvas = document.createElement('canvas'); - canvas.width = targetSize; - canvas.height = targetSize; + canvas.width = target.width; + canvas.height = target.height; const c = canvas.getContext('2d', { willReadFrequently: true }); c.imageSmoothingEnabled = false; - c.clearRect(0, 0, targetSize, targetSize); - const fit = fitImageInsideSquare(img.naturalWidth || img.width, img.naturalHeight || img.height, targetSize); - c.drawImage(img, fit.dx, fit.dy, fit.dw, fit.dh); - const data = c.getImageData(0, 0, targetSize, targetSize).data; - const next = blankPixels(targetSize); - for (let i = 0; i < targetSize * targetSize; i++) { + c.clearRect(0, 0, target.width, target.height); + c.drawImage(img, 0, 0, target.width, target.height); + const data = c.getImageData(0, 0, target.width, target.height).data; + const next = blankPixels(target.width, target.height); + for (let i = 0; i < target.width * target.height; i++) { const alpha = data[i * 4 + 3]; if (alpha < 32) continue; const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]); next[i] = nearestPaletteCode(hex); } - editorSize = targetSize; - els.assetSize.value = String(targetSize); + editorWidth = target.width; + editorHeight = target.height; + editorSize = Math.max(target.width, target.height); + updateDimensionInputs(); setDisplayEditorPixels(next); - depthPixels = blankPixels(targetSize).map(() => 0); + depthPixels = Array(editorSize * editorSize).fill(0); lightPixels = []; - doorPixel = { x: Math.min(doorPixel.x, targetSize - 1), y: Math.min(doorPixel.y, targetSize - 1) }; + particlePixels = []; + doorPixel = { x: Math.min(doorPixel.x, target.width - 1), y: Math.min(doorPixel.y, target.height - 1) }; editorSelection = null; - if (previousSize !== targetSize) resetEditorView(); + if (previousSize !== editorSize) resetEditorView(); return true; }); URL.revokeObjectURL(img.src); - const note = (img.naturalWidth === targetSize && img.naturalHeight === targetSize) ? '' : ` (fit from ${img.naturalWidth}×${img.naturalHeight})`; - toast(`PNG imported as ${targetSize}×${targetSize}${note}.`); + const note = (img.naturalWidth === target.width && img.naturalHeight === target.height) ? '' : ` (scaled from ${img.naturalWidth}×${img.naturalHeight})`; + toast(`PNG imported as ${target.width}×${target.height}${note}.`); }; img.onerror = () => toast('Could not read that PNG.'); img.src = URL.createObjectURL(file); } function chooseImportCanvasSize(width, height) { - const allowed = [8, 16, 32, 64]; const w = Math.max(1, Math.round(Number(width) || 1)); const h = Math.max(1, Math.round(Number(height) || 1)); - if (w === h && allowed.includes(w)) return w; - const target = Math.max(w, h); - return allowed.find((size) => target <= size) || allowed[allowed.length - 1]; - } - - function fitImageInsideSquare(width, height, size) { - const w = Math.max(1, Number(width) || 1); - const h = Math.max(1, Number(height) || 1); - const scale = Math.min(size / w, size / h); - const dw = Math.max(1, Math.round(w * scale)); - const dh = Math.max(1, Math.round(h * scale)); + const scale = Math.min(1, MAX_EDITOR_DIMENSION / Math.max(w, h)); return { - dx: Math.floor((size - dw) / 2), - dy: Math.floor((size - dh) / 2), - dw, - dh + width: clampDimension(Math.round(w * scale), Math.min(w, MAX_EDITOR_DIMENSION)), + height: clampDimension(Math.round(h * scale), Math.min(h, MAX_EDITOR_DIMENSION)) }; } @@ -2308,20 +2515,100 @@ return { x, y }; } - function setupEditor(size, rightPixels, leftPixels = null, nextDepthPixels = null) { - editorSize = size; - const normalizedRight = normalizePixels(rightPixels, size); - const normalizedLeft = leftPixels ? normalizePixels(leftPixels, size) : null; - const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorPixels(normalizedLeft, size); - editorPixels = normalizePixels(canonical, size); - editorLeftPixels = mirrorPixels(editorPixels, size); + function compactPixelsFromStride(pixels, stride, width, height) { + const source = normalizePixels(pixels, stride); + const out = blankPixels(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * width + x] = source[y * stride + x] || null; + } + return out; + } + + function compactDepthFromStride(values, stride, width, height) { + const source = normalizeDepthPixels(values, stride); + const out = Array(width * height).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * width + x] = source[y * stride + x] || 0; + } + return out; + } + + function inflatePixelsToStride(pixels, width, height, stride) { + const source = normalizePixels(pixels, width, height); + const out = blankPixels(stride); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * stride + x] = source[y * width + x] || null; + } + return out; + } + + function inflateDepthToStride(values, width, height, stride) { + const source = normalizeDepthPixels(values, width, height); + const out = Array(stride * stride).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * stride + x] = source[y * width + x] || 0; + } + return out; + } + + function resizeEditorPlane(source, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, fill = null) { + const out = Array(newStride * newStride).fill(fill); + const minW = Math.min(oldWidth, newWidth); + const minH = Math.min(oldHeight, newHeight); + const src = Array.isArray(source) ? source : []; + for (let y = 0; y < minH; y++) { + for (let x = 0; x < minW; x++) out[y * newStride + x] = src[y * oldStride + x] ?? fill; + } + return out; + } + + function resizeEditorCanvas(nextWidth, nextHeight) { + const newWidth = clampDimension(nextWidth, editorWidth); + const newHeight = clampDimension(nextHeight, editorHeight); + const newStride = Math.max(newWidth, newHeight); + if (newWidth === editorWidth && newHeight === editorHeight && newStride === editorSize) return false; + commitEditorMutation(() => { + const oldStride = editorSize; + const oldWidth = editorWidth; + const oldHeight = editorHeight; + const nextPixels = resizeEditorPlane(editorPixels, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, null); + const nextDepth = resizeEditorPlane(depthPixels, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, 0); + editorWidth = newWidth; + editorHeight = newHeight; + editorSize = newStride; + editorPixels = normalizePixels(nextPixels, editorSize); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); + depthPixels = normalizeDepthPixels(nextDepth, editorSize); + lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, false); + particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < editorWidth && p.y < editorHeight && editorPixels[p.y * editorSize + p.x]); + doorPixel = { x: clamp(doorPixel.x, 0, editorWidth - 1), y: clamp(doorPixel.y, 0, editorHeight - 1) }; + editorSelection = null; + updateDimensionInputs(); + resetEditorView(); + return true; + }); + return true; + } + + function setupEditor(sizeOrWidth, rightPixels, leftPixels = null, nextDepthPixels = null, nextHeight = null) { + const width = clampDimension(sizeOrWidth, 8); + const height = clampDimension(nextHeight ?? sizeOrWidth, width); + const stride = Math.max(width, height); + editorWidth = width; + editorHeight = height; + editorSize = stride; + const normalizedRight = inflatePixelsToStride(rightPixels, width, height, stride); + const normalizedLeft = leftPixels ? inflatePixelsToStride(leftPixels, width, height, stride) : null; + const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorEditorPixelsHorizontal(normalizedLeft, stride, width, height); + editorPixels = normalizePixels(canonical, stride); + editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, stride, width, height); editorSelection = null; - depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size); - els.assetSize.value = String(size); - lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); - particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); + depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? inflateDepthToStride(nextDepthPixels, width, height, stride) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || stride, stride); + updateDimensionInputs(); + lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, false); + particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < editorWidth && p.y < editorHeight && editorPixels[p.y * editorSize + p.x]); particleConfig = normalizeParticleConfig(particleConfig); - doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; + doorPixel = { x: clamp(doorPixel.x, 0, editorWidth - 1), y: clamp(doorPixel.y, 0, editorHeight - 1) }; resetEditorView(); drawEditor(); updateEditorHistoryButtons(); @@ -2331,18 +2618,23 @@ clampEditorView(); const canvas = els.paintCanvas; const rectSize = canvas.width; - const cell = rectSize / editorSize; + const cell = editorCellSize(); + const activeW = editorWidth * cell; + const activeH = editorHeight * cell; pctx.clearRect(0, 0, rectSize, rectSize); pctx.fillStyle = '#fffaf0'; pctx.fillRect(0, 0, rectSize, rectSize); + pctx.fillStyle = 'rgba(36, 48, 68, .05)'; + if (activeW < rectSize) pctx.fillRect(activeW, 0, rectSize - activeW, rectSize); + if (activeH < rectSize) pctx.fillRect(0, activeH, rectSize, rectSize - activeH); pctx.save(); pctx.translate(editorView.x, editorView.y); pctx.scale(editorView.zoom, editorView.zoom); const pixels = getDisplayEditorPixels(); - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; x++) { + for (let y = 0; y < editorHeight; y++) { + for (let x = 0; x < editorWidth; x++) { const color = pixels[y * editorSize + x]; if (!color) continue; pctx.fillStyle = colorToHex(color); @@ -2355,8 +2647,8 @@ pctx.fillStyle = 'rgba(46, 89, 160, .07)'; pctx.fillRect(0, 0, rectSize, rectSize); } - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; x++) { + for (let y = 0; y < editorHeight; y++) { + for (let x = 0; x < editorWidth; x++) { const source = displayCellToCanonical(x, y); const depth = depthPixels[source.y * editorSize + source.x] || 0; if (!depth) continue; @@ -2372,10 +2664,13 @@ pctx.strokeStyle = 'rgba(36, 48, 68, .13)'; pctx.lineWidth = 1 / editorView.zoom; - for (let i = 0; i <= editorSize; i++) { + for (let i = 0; i <= editorWidth; i++) { const p = Math.round(i * cell) + .5; - pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, rectSize); pctx.stroke(); - pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(rectSize, p); pctx.stroke(); + pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, activeH); pctx.stroke(); + } + for (let i = 0; i <= editorHeight; i++) { + const p = Math.round(i * cell) + .5; + pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(activeW, p); pctx.stroke(); } { @@ -2423,17 +2718,51 @@ function clampEditorView() { const size = els.paintCanvas.width; - const scaled = size * editorView.zoom; - const minOffset = size - scaled; - editorView.x = clamp(editorView.x, minOffset, 0); - editorView.y = clamp(editorView.y, minOffset, 0); + const cell = editorCellSize(); + const scaledW = editorWidth * cell * editorView.zoom; + const scaledH = editorHeight * cell * editorView.zoom; + const minX = Math.min(0, size - scaledW); + const minY = Math.min(0, size - scaledH); + editorView.x = clamp(editorView.x, minX, 0); + editorView.y = clamp(editorView.y, minY, 0); + } + + function sanitizeLightPixels(points, pixels, stride, width = stride, height = width, fallbackColor = selectedColorCode, announce = false) { + const source = normalizePixels(pixels || [], stride); + const maxLights = lightBudgetForArea(width, height); + const seen = new Set(); + const clean = []; + for (const raw of Array.isArray(points) ? points : []) { + const x = clampInt(raw.x, 0, width - 1, 0); + const y = clampInt(raw.y, 0, height - 1, 0); + const key = `${x},${y}`; + if (seen.has(key)) continue; + if (!source[y * stride + x]) continue; + seen.add(key); + clean.push({ x, y, c: raw.c || fallbackColor }); + if (clean.length >= maxLights) break; + } + if (announce && clean.length < (Array.isArray(points) ? points.length : 0)) toast(`Light cells limited to ${maxLights} and must sit on non-transparent pixels.`); + return clean; } function addLightPixel(x, y, colorCode = selectedColorCode) { - const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode }; + const point = { x: clamp(Math.floor(Number(x)), 0, editorWidth - 1), y: clamp(Math.floor(Number(y)), 0, editorHeight - 1), c: colorCode }; + if (!editorPixels[point.y * editorSize + point.x]) { + toast('Light cells must be placed on painted pixels.'); + return; + } const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y); if (existing) existing.c = colorCode; - else lightPixels.push(point); + else { + const maxLights = lightBudgetForArea(editorWidth, editorHeight); + if (lightPixels.length >= maxLights) { + toast(`Light limit: ${maxLights} for ${editorWidth}×${editorHeight} cells.`); + return; + } + lightPixels.push(point); + } + lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, true); } function removeLightPixel(x, y) { @@ -2441,7 +2770,7 @@ } 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 point = { x: clamp(Math.floor(Number(x)), 0, editorWidth - 1), y: clamp(Math.floor(Number(y)), 0, editorHeight - 1), c: colorCode, dir: ['up','down','left','right'].includes(dir) ? dir : 'up' }; const existing = particlePixels.find((p) => p.x === point.x && p.y === point.y); if (existing) { existing.c = colorCode; existing.dir = point.dir; } else particlePixels.push(point); @@ -2452,51 +2781,153 @@ } - function mirrorLightPointsHorizontal(points, size) { + function mirrorLightPointsHorizontal(points, width, height = width) { 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); + .map((p) => ({ ...p, x: width - 1 - clampInt(p.x, 0, width - 1, 0) })) + .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } - function mirrorParticlePointsHorizontal(points, size) { + function mirrorParticlePointsHorizontal(points, width, height = width) { 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); + .map((p) => ({ ...p, x: width - 1 - clampInt(p.x, 0, width - 1, 0), dir: p.dir === 'left' ? 'right' : p.dir === 'right' ? 'left' : (p.dir || 'up') })) + .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } - function shiftParticlePointsVertical(points, size, shift = 0) { + function shiftParticlePointsVertical(points, width, height = width, 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); + .map((p) => ({ ...p, y: clampInt(p.y, 0, height - 1, 0) + shift })) + .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } - function normalizeEditorOrientationForSave(size) { + function mirrorEditorPixelsHorizontal(pixels, stride, width, height) { + const source = normalizePixels(pixels, stride); + const out = blankPixels(stride); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * stride + (width - 1 - x)] = source[y * stride + x] || null; + } + return out; + } + + function mirrorEditorDepthHorizontal(values, stride, width, height) { + const source = normalizeDepthPixels(values, stride); + const out = Array(stride * stride).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * stride + (width - 1 - x)] = source[y * stride + x] || 0; + } + return out; + } + + function normalizeEditorOrientationForSave() { + const stride = editorSize; const dynamicLeftCanvas = roleToCategory(currentRole()) === 'dynamic' && editingSide === 'left'; - const rightPixels = dynamicLeftCanvas ? 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 }; + const rightPixels = dynamicLeftCanvas ? mirrorEditorPixelsHorizontal(editorPixels, stride, editorWidth, editorHeight) : normalizePixels(editorPixels, stride); + const normalizedDepth = normalizeDepthPixels(depthPixels, stride); + const orientedDepth = dynamicLeftCanvas ? mirrorEditorDepthHorizontal(normalizedDepth, stride, editorWidth, editorHeight) : normalizedDepth; + const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal(lightPixels, editorWidth, editorHeight) : lightPixels.map((p) => ({ ...p })); + const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal(particlePixels, editorWidth, editorHeight) : particlePixels.map((p) => ({ ...p })); + const orientedParticleConfig = normalizeParticleConfig({ ...particleConfig, enabled: orientedParticles.length > 0 }, stride); + return { rightPixels, depthPixels: orientedDepth, lightPixels: orientedLights, particlePixels: orientedParticles, particleConfig: orientedParticleConfig, mirroredFromLeft: dynamicLeftCanvas, width: editorWidth, height: editorHeight, stride }; } + function getBottomShiftRect(pixels, stride, width, height) { + let maxY = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) if (pixels[y * stride + x]) maxY = Math.max(maxY, y); + } + return maxY < 0 ? 0 : height - 1 - maxY; + } - function alignEditorStateToBottom(size) { - const oriented = normalizeEditorOrientationForSave(size); - const shift = getBottomShift(oriented.rightPixels, size); + function shiftPixelsVerticalRect(pixels, stride, width, height, shift) { + const out = blankPixels(stride); + const source = normalizePixels(pixels, stride); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ny = y + shift; + if (ny >= 0 && ny < height) out[ny * stride + x] = source[y * stride + x] || null; + } + } + return out; + } + + function shiftDepthPixelsVerticalRect(pixels, stride, width, height, shift) { + const source = normalizeDepthPixels(pixels, stride); + const out = Array(stride * stride).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ny = y + shift; + if (ny >= 0 && ny < height) out[ny * stride + x] = source[y * stride + x] || 0; + } + } + return out; + } + + function trimEditorStateForSave(aligned) { + const { stride, width, height } = aligned; + let minX = width, minY = height, maxX = -1, maxY = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (!aligned.rightPixels[y * stride + x]) continue; + minX = Math.min(minX, x); minY = Math.min(minY, y); + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + } + } + if (maxX < 0) return null; + const outW = maxX - minX + 1; + const outH = maxY - minY + 1; + const outPixels = blankPixels(outW, outH); + const outDepth = Array(outW * outH).fill(0); + for (let y = 0; y < outH; y++) { + for (let x = 0; x < outW; x++) { + const src = (minY + y) * stride + (minX + x); + const dst = y * outW + x; + outPixels[dst] = aligned.rightPixels[src] || null; + outDepth[dst] = aligned.depthPixels[src] || 0; + } + } + const lightPixels = sanitizeLightPixels( + aligned.lightPixels.map((p) => ({ ...p, x: p.x - minX, y: p.y - minY })), + outPixels, + outW, + outW, + outH, + selectedColorCode, + false + ); + const particlePixels = aligned.particlePixels + .map((p) => ({ ...p, x: p.x - minX, y: p.y - minY })) + .filter((p) => p.x >= 0 && p.y >= 0 && p.x < outW && p.y < outH && outPixels[p.y * outW + p.x]); return { - rightPixels: shiftPixelsVertical(oriented.rightPixels, size, shift), - depthPixels: shiftDepthPixelsVertical(oriented.depthPixels, size, shift), + width: outW, + height: outH, + size: Math.max(outW, outH), + rightPixels: outPixels, + depthPixels: outDepth, + lightPixels, + particlePixels, + particleConfig: normalizeParticleConfig({ ...aligned.particleConfig, enabled: particlePixels.length > 0 }, Math.max(outW, outH)), + door: aligned.door ? { x: clamp(aligned.door.x - minX, 0, outW - 1), y: clamp(aligned.door.y - minY, 0, outH - 1) } : null, + mirroredFromLeft: aligned.mirroredFromLeft, + crop: { x: minX, y: minY, w: outW, h: outH } + }; + } + + function alignEditorStateToBottom() { + const oriented = normalizeEditorOrientationForSave(); + const shift = getBottomShiftRect(oriented.rightPixels, oriented.stride, oriented.width, oriented.height); + const aligned = { + ...oriented, + rightPixels: shiftPixelsVerticalRect(oriented.rightPixels, oriented.stride, oriented.width, oriented.height, shift), + depthPixels: shiftDepthPixelsVerticalRect(oriented.depthPixels, oriented.stride, oriented.width, oriented.height, shift), lightPixels: oriented.lightPixels .map((p) => ({ ...p, y: p.y + shift })) - .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), - 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) }, + .filter((p) => p.x >= 0 && p.x < oriented.width && p.y >= 0 && p.y < oriented.height), + particlePixels: shiftParticlePointsVertical(oriented.particlePixels, oriented.width, oriented.height, shift), + door: { x: clamp(doorPixel.x, 0, oriented.width - 1), y: clamp(doorPixel.y + shift, 0, oriented.height - 1) }, mirroredFromLeft: oriented.mirroredFromLeft }; + const trimmed = trimEditorStateForSave(aligned); + return trimmed || aligned; } function getBottomShift(pixels, size) { @@ -2540,7 +2971,7 @@ const category = roleToCategory(role); const subtype = roleToSubtype(role); const size = editorSize; - const existing = null; + const existing = editingAssetId ? findAsset(editingAssetId) : null; const paintedDots = countPixels(editorPixels); if (paintedDots < 10) { toast('Draw at least 10 pixels before saving.'); @@ -2551,26 +2982,37 @@ return null; } const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`; - const aligned = alignEditorStateToBottom(size); + const aligned = alignEditorStateToBottom(); + const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); const asset = { id: existing?.id || uid(), name, category, subtype, - size, + size: savedSize, + width: aligned.width || savedSize, + height: aligned.height || savedSize, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now(), author: existing?.author || state.authorName || 'Local Artist', + ownerAccountId: normalizeOwnerAccountId(existing?.ownerAccountId, currentAccountId()), + version: Number(existing?.version || 0) + 1, parentAssetId: existing ? existing.parentAssetId : editParentId, originalAssetId: existing ? existing.originalAssetId : editOriginalId, - pixels: encodePixels(aligned.rightPixels), + pixels: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), faces: category === 'dynamic' ? { - right: encodePixels(aligned.rightPixels), + right: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), left: 'mirror' } : null, - meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels) + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, aligned.width || savedSize, aligned.particlePixels, aligned.rightPixels, aligned.height || savedSize) }; asset.contentHash = computeAssetContentHash(asset); + const duplicate = existing ? null : findEquivalentCollectionAsset(asset); + if (duplicate) { + selectedAssetId = duplicate.id; + toast(`${duplicate.name} is already in Collection.`); + return duplicate; + } if (existing) { const index = state.assets.findIndex((a) => a.id === existing.id); if (index >= 0) state.assets[index] = asset; @@ -2619,14 +3061,15 @@ toast('Draw at least 10 pixels before checking on the island.'); return null; } - const aligned = alignEditorStateToBottom(size); + const aligned = alignEditorStateToBottom(); + const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); const asset = { id: `preview:${Date.now()}`, name: (els.assetName.value || '').trim() || 'Preview work', - category, subtype, size, author: state.authorName || 'Local Artist', - pixels: encodePixels(aligned.rightPixels), - faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels), left: 'mirror' } : null, - meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels) + category, subtype, size: savedSize, width: aligned.width || savedSize, height: aligned.height || savedSize, author: state.authorName || 'Local Artist', + pixels: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), + faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), left: 'mirror' } : null, + meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, aligned.width || savedSize, aligned.particlePixels, aligned.rightPixels, aligned.height || savedSize) }; return asset; } @@ -2673,7 +3116,7 @@ editParentId = null; editOriginalId = null; els.assetName.value = ''; - els.assetCategory.value = 'nature'; + els.assetCategory.value = ''; staticKind = 'nature'; dynamicKind = 'human'; editingSide = 'right'; @@ -2753,8 +3196,9 @@ 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.'; + els.accountNote.textContent = 'Save + Place creates a local account for island publishing.'; if (els.createAccount) els.createAccount.hidden = false; + updatePublishQuotaUI(); return; } state.account = normalizeAccount(state.account); @@ -2762,10 +3206,19 @@ 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 = `Account ${day}; publish ${quota.used}/${quota.limit} this hour. Change the password from the generated default.`; + els.accountNote.textContent = `Account ${day}.`; if (els.createAccount) els.createAccount.hidden = true; + updatePublishQuotaUI(); + } + + function updatePublishQuotaUI() { + const quota = getPublishQuotaStatus(); + const text = quota.accountRequired ? 'Publish: account needed' : `Publish: ${quota.remaining}/${quota.limit} left`; + [els.drawQuotaBadge, els.finishQuotaBadge].filter(Boolean).forEach((badge) => { + badge.textContent = text; + badge.classList.toggle('quotaEmpty', !quota.accountRequired && quota.remaining <= 0); + }); } function focusAssetInWorld(asset) { @@ -2796,9 +3249,13 @@ return; } const myName = (state.authorName || 'Local Artist').trim() || 'Local Artist'; + const myAccountId = currentAccountId(); const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter; - const mine = visibleAssets.filter((asset) => (asset.author || 'Local Artist') === myName && matchesFilter(asset)); - const others = visibleAssets.filter((asset) => (asset.author || 'Local Artist') !== myName && matchesFilter(asset)); + const isMyAsset = (asset) => isSharedWorld() + ? normalizeOwnerAccountId(asset.ownerAccountId) === myAccountId + : (asset.author || 'Local Artist') === myName; + const mine = visibleAssets.filter((asset) => isMyAsset(asset) && matchesFilter(asset)); + const others = visibleAssets.filter((asset) => !isMyAsset(asset) && matchesFilter(asset)); addLibrarySection('My works', mine); addLibrarySection('Others', others); @@ -2868,7 +3325,7 @@ if (expanded) { const lineage = asset.parentAssetId ? ' / derivative' : ''; const span1 = document.createElement('span'); - span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`; + span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${assetWidth(asset)}×${assetHeight(asset)}${lineage}`; const span2 = document.createElement('span'); span2.textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`; meta.append(span1, span2); @@ -2885,8 +3342,11 @@ down.classList.toggle('mutedVote', assetPreviousVote > 0); const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); hide.classList.toggle('mutedAction', assetPreviousVote >= 0); - const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + const isMine = isSharedWorld() + ? normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId() + : (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); }); + const edit = isMine ? makeButton('Edit', (event) => { event?.stopPropagation?.(); editOriginalAsset(asset); }) : null; const move = isMine ? makeButton('Place/Move', (event) => { event?.stopPropagation?.(); selectedAssetId = asset.id; @@ -2899,12 +3359,13 @@ const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); actions.append(up, down, hide, remix); + if (edit) actions.append(edit); if (move) actions.append(move); if (isMine) actions.append(del); meta.append(actions); } else { const mini = document.createElement('span'); - mini.textContent = `${cap(asset.subtype)} · ${asset.size}×${asset.size}`; + mini.textContent = `${cap(asset.subtype)} · ${assetWidth(asset)}×${assetHeight(asset)}`; meta.append(mini); } @@ -3077,12 +3538,14 @@ c.fillStyle = '#fff3d9'; c.fillRect(0, 0, canvas.width, canvas.height); const pixels = getAssetPixels(asset, 'right'); - const scale = Math.floor(48 / asset.size) || 1; - const ox = Math.floor((canvas.width - asset.size * scale) / 2); - const oy = Math.floor((canvas.height - asset.size * scale) / 2); - for (let y = 0; y < asset.size; y++) { - for (let x = 0; x < asset.size; x++) { - const color = pixels[y * asset.size + x]; + const w = assetWidth(asset); + const h = assetHeight(asset); + const scale = Math.floor(48 / Math.max(w, h)) || 1; + const ox = Math.floor((canvas.width - w * scale) / 2); + const oy = Math.floor((canvas.height - h * scale) / 2); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const color = pixels[y * w + x]; if (!color) continue; c.fillStyle = colorToHex(color); c.fillRect(ox + x * scale, oy + y * scale, scale, scale); @@ -3128,11 +3591,11 @@ function loadAssetIntoEditor(asset, mode = 'remix') { setDrawerOpen(true); setTab('draw'); - const editExisting = false; - editingAssetId = null; - editParentId = asset.id; - editOriginalId = asset.originalAssetId || asset.id; - els.assetName.value = `${asset.name} Remix`; + const editExisting = mode === 'edit'; + 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 || 'Untitled') : `${asset.name} Remix`; els.assetCategory.value = subtypeToRole(asset); staticKind = asset.category === 'static' ? asset.subtype : staticKind; dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; @@ -3140,17 +3603,19 @@ 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, 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); + const aw = assetWidth(asset); + const ah = assetHeight(asset); + particleConfig = normalizeParticleConfig((particlePixels.length ? { enabled: true, c: particlePixels[0].c || selectedColorCode, dir: particlePixels[0].dir || 'up' } : { enabled: false, c: selectedColorCode, dir: 'up' }), Math.max(aw, ah)); + depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], aw, ah); + doorPixel = asset.meta?.door || { x: Math.floor(aw / 2), y: ah - 1 }; + setupEditor(aw, right, null, depthPixels, ah); clearEditorHistory(); refreshCategoryUI(); - els.lineageNote.textContent = `Remixing “${asset.name}”. Save creates a separate new collection work.`; + els.lineageNote.textContent = editExisting ? `Editing “${asset.name}”. Save updates this collection work.` : `Remixing “${asset.name}”. Save creates a separate new collection work.`; } function editOriginalAsset(asset) { - remixEdit(asset); + loadAssetIntoEditor(asset, 'edit'); } function remixEdit(asset) { @@ -3164,6 +3629,11 @@ function deleteAsset(asset) { if (!asset) return; + if (isSharedWorld()) { + if (!canCurrentAccountDeleteAsset(asset)) return; + queueSharedCommand(makeSharedCommand('asset.delete', { assetId: asset.id })); + return; + } const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); const usageText = used ? ' Island placements that use it will also be removed.' : ''; if (!confirm(`Delete “${asset.name}”?${usageText}`)) return; @@ -3398,7 +3868,7 @@ function updateClock() { const phase = getPhase(); const progress = phase.progress || 0; - const degrees = progress * 360; + const degrees = progress * 360 - 5; 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`); @@ -3540,8 +4010,9 @@ 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; + const aw = assetWidth(asset); for (const light of asset.meta.lightPixels) { - const lx = asset.category === 'dynamic' && info.side === 'left' ? asset.size - 1 - light.x : light.x; + const lx = asset.category === 'dynamic' && info.side === 'left' ? aw - 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) { @@ -3556,7 +4027,8 @@ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), - radius: lerp(13, 22, asset.size / 64), + radius: lerp(13, 22, assetMaxSize(asset) / 64), + intensity: clamp(0.82 + assetMaxSize(asset) / 96, 0.85, 1.35), ownerId: item.source?.id || asset.id, flickerSeed: parseInt(fnv1a(`${item.source?.id || asset.id}:${light.x},${light.y}`).slice(0, 6), 16) || 1 }); @@ -3565,9 +4037,60 @@ return sources; } + + function updateCameraFollowSelected() { + if (!cameraFollowSelected || !selectedObject) return; + const selected = getSelectedWorldPosition(); + if (!selected) return; + const targetX = cw / 2 - selected.pos.x * view.zoom; + const targetY = ch * 0.52 - (selected.pos.y - Math.max(18, assetMaxSize(selected.asset) * 0.55)) * view.zoom; + view.x = lerp(view.x, targetX, 0.24); + view.y = lerp(view.y, targetY, 0.24); + } + + function ensureShadowMaskCanvas() { + if (!shadowMaskCanvas) { + shadowMaskCanvas = document.createElement('canvas'); + shadowMaskCtx = shadowMaskCanvas.getContext('2d', { alpha: true }); + } + const w = Math.max(1, Math.ceil(cw * dpr)); + const h = Math.max(1, Math.ceil(ch * dpr)); + if (shadowMaskCanvas.width !== w || shadowMaskCanvas.height !== h) { + shadowMaskCanvas.width = w; + shadowMaskCanvas.height = h; + } + shadowMaskCtx.setTransform(1, 0, 0, 1, 0, 0); + shadowMaskCtx.clearRect(0, 0, shadowMaskCanvas.width, shadowMaskCanvas.height); + shadowMaskCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + shadowMaskCtx.translate(view.x, view.y); + shadowMaskCtx.scale(view.zoom, view.zoom); + shadowMaskCtx.imageSmoothingEnabled = false; + return shadowMaskCtx; + } + + function beginProjectedShadowLayer(phase) { + const alpha = phase?.shadow?.alpha || 0; + if (alpha <= 0.001) return false; + activeShadowCtx = ensureShadowMaskCanvas(); + activeShadowCtx.globalCompositeOperation = 'source-over'; + return true; + } + + function endProjectedShadowLayer(phase) { + if (!activeShadowCtx || !shadowMaskCanvas) return; + activeShadowCtx = null; + ctx.save(); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.globalAlpha = phase?.shadow?.alpha || 0.16; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(shadowMaskCanvas, 0, 0, cw, ch); + ctx.restore(); + } + function render(time = performance.now()) { const phase = getPhase(); renderPhase = phase; + updateCameraFollowSelected(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cw, ch); ctx.fillStyle = phase.sky || '#86d5ff'; @@ -3578,10 +4101,21 @@ ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; drawTerrainCache(terrainCache); - drawHoverTile(); lastRenderedSpriteInfo.clear(); const lightSources = []; - const visibleItems = drawObjects(time, lightSources, phase); + const visibleItems = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); + const activeLights = collectLightSourcesForItems(visibleItems, time, phase); + if (lightSources && activeLights.length) lightSources.push(...activeLights); + if (beginProjectedShadowLayer(phase)) { + drawTerrainProjectedShadows(phase); + drawObjectProjectedShadows(visibleItems, time, phase); + endProjectedShadowLayer(phase); + } else { + drawTerrainProjectedShadows(phase); + drawObjectProjectedShadows(visibleItems, time, phase); + } + drawHoverTile(); + drawObjectsFromItems(visibleItems, time, activeLights, phase, false); if (visualSettings().enableParticles) { spawnConfiguredParticleEmitters(visibleItems, time); drawNatureDriftParticles(time); @@ -3590,8 +4124,10 @@ drawSpawnEffects(time); drawConfettiParticles(time); } - drawTerrainFrontCache(terrainFrontCache); const nightLightsActive = areNightLightsActive(phase); + if (nightLightsActive) { + drawWaterLightReflections(lightSources, time, phase); + } ctx.restore(); updateSelectionBubble(time); @@ -3604,12 +4140,53 @@ ctx.fillRect(0, 0, cw, ch); } if (nightLightsActive && phase.darkness > 0) drawLightSources(lightSources, phase.darkness, time); + if (nightLightsActive) drawNightCursorGlow(time, phase); + } + + function drawTerrainProjectedShadows(phase) { + const shadow = phase?.shadow || { alpha: .16, length: 1, skewX: 0, scaleY: 0.28 }; + if ((shadow.alpha || 0) <= 0.001) return; + const c = activeShadowCtx || ctx; + const rect = getViewportWorldRect(2); + c.save(); + c.globalAlpha = activeShadowCtx ? 1 : shadow.alpha; + c.fillStyle = '#1b1f26'; + c.imageSmoothingEnabled = false; + for (let ty = Math.max(0, Math.floor(rect.minY) - 2); ty <= Math.min(WORLD_H - 1, Math.ceil(rect.maxY) + 2); ty++) { + for (let tx = Math.max(0, Math.floor(rect.minX) - 2); tx <= Math.min(WORLD_W - 1, Math.ceil(rect.maxX) + 2); tx++) { + const tile = world.get(tx, ty); + if (!tile || tile.type !== 'highland') continue; + const lift = getTileLift(tile); + if (lift <= 0) continue; + const front = world.get(tx, ty + 1); + const right = world.get(tx + 1, ty); + const left = world.get(tx - 1, ty); + const needsShadow = !front || front.type === 'water' || getTileLift(front) < lift || !right || getTileLift(right) < lift || !left || getTileLift(left) < lift; + if (!needsShadow) continue; + const pos = tileToWorld(tx, ty); + const cx = pos.x; + const cy = pos.y + TILE_H * 0.72; + c.save(); + c.translate(cx, cy); + c.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); + c.beginPath(); + c.moveTo(0, -TILE_H * 0.42); + c.lineTo(TILE_W * 0.48, 0); + c.lineTo(0, TILE_H * 0.42); + c.lineTo(-TILE_W * 0.48, 0); + c.closePath(); + c.fill(); + c.restore(); + } + } + c.restore(); } function drawHoverTile() { if (!hoverTile) return; const { x, y } = tileToWorld(hoverTile.x, hoverTile.y); - const lift = getTileLift(hoverTile.tile); + const hoverTerrain = world.get(hoverTile.x, hoverTile.y); + const lift = getTileLift(hoverTerrain); ctx.beginPath(); ctx.moveTo(x, y - lift); ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); @@ -3621,6 +4198,26 @@ ctx.stroke(); } + + function drawNightCursorGlow(time, phase) { + if (!areNightLightsActive(phase)) return; + const sx = cursorScreen.active ? cursorScreen.x : cw / 2; + const sy = cursorScreen.active ? cursorScreen.y : ch / 2; + const pulse = 0.86 + Math.sin(time / 420) * 0.14; + const radius = Math.max(52, 86 * view.zoom) * pulse; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius); + glow.addColorStop(0, 'rgba(142, 218, 255, .30)'); + glow.addColorStop(0.32, 'rgba(142, 218, 255, .13)'); + glow.addColorStop(1, 'rgba(142, 218, 255, 0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(sx, sy, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + function drawSpawnEffects(time) { if (!spawnEffects.length) return; spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650); @@ -3645,22 +4242,24 @@ } } - 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); - + function drawObjectsFromItems(items, time, activeLights, phase, drawShadows = true) { for (const item of items) { if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { - drawSpriteItem(item, time, activeLights, true, phase); + drawSpriteItem(item, time, activeLights, true, phase, drawShadows); } } for (const item of items) { if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) { - drawSpriteItem(item, time, activeLights, false, phase); + drawSpriteItem(item, time, activeLights, false, phase, drawShadows); } } - return items; + } + + function drawObjectProjectedShadows(items, time, phase) { + for (const item of items) { + if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') continue; + drawSpriteShadow(getSpriteDrawInfo(item, time, true), phase); + } } function getSpriteDrawInfo(item, time, includeSelectBounce = true) { @@ -3721,9 +4320,12 @@ const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1; const tiltRand = pseudoNoise(tiltSeed * 0.013) - 0.5; angle += tiltRand * 0.32 * bounce; - const land = Math.exp(-Math.pow((selectedT - 0.92) / 0.09, 2)); - stretchX += land * 0.26; - stretchY -= land * 0.18; + // Selection landing squash/stretch: keep it tied to the visible landing moment. + // The previous delayed timer placed the squash after the bounce had already ended, + // so it looked like the aspect-ratio animation had disappeared. + const land = Math.exp(-Math.pow((selectedT - 0.88) / 0.085, 2)); + stretchX += land * 0.30; + stretchY -= land * 0.21; } } @@ -3734,14 +4336,14 @@ return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY, stretchX, stretchY }; } - function drawSpriteItem(item, time, lightSources, underwater, phase) { + function drawSpriteItem(item, time, lightSources, underwater, phase, drawShadow = true) { const info = getSpriteDrawInfo(item, time, true); const { asset, pos, sprite, drawX, drawY, alpha, angle, stretchX = 1, stretchY = 1 } = info; lastRenderedSpriteInfo.set(item.source?.id || asset.id, { ...info, time }); - if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id); + if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id, asset); - if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(info, phase); + if (drawShadow && !(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(info, phase); ctx.save(); ctx.globalAlpha = alpha; @@ -3780,15 +4382,61 @@ }; } + + function drawWaterLightReflections(lightSources, time, phase) { + if (!lightSources?.length || !areNightLightsActive(phase)) return; + const rect = getViewportWorldRect(48); + const t = time * 0.001; + ctx.save(); + ctx.imageSmoothingEnabled = false; + for (const source of lightSources.slice(0, 80)) { + const rgb = parseHex(source.color || '#ffd86a'); + if (!rgb) continue; + const tilePos = worldToTileFloat(source.x, source.y + 4); + const reachTiles = clamp(Math.ceil((source.radius || 18) / 12) + 2, 2, 7); + const baseX = Math.round(tilePos.x); + const baseY = Math.round(tilePos.y); + for (let ty = baseY - reachTiles; ty <= baseY + reachTiles; ty++) { + if (ty < 0 || ty >= WORLD_H) continue; + for (let tx = baseX - reachTiles; tx <= baseX + reachTiles; tx++) { + if (tx < 0 || tx >= WORLD_W) continue; + const tile = world.get(tx, ty); + if (!tile || tile.type !== 'water') continue; + const pos = tileToWorld(tx, ty); + const px = pos.x; + const py = pos.y + TILE_H * 0.45; + if (px + 24 < rect.left || px - 24 > rect.right || py + 18 < rect.top || py - 18 > rect.bottom) continue; + const dx = (px - source.x) / Math.max(1, source.radius || 18); + const dy = (py - source.y) / Math.max(1, (source.radius || 18) * 0.75); + const dist = Math.hypot(dx, dy); + const strength = 1 - clamp(dist / 1.85, 0, 1); + if (strength <= 0.02) continue; + const seed = tx * 17.13 + ty * 31.71 + (source.flickerSeed || 0) * 0.003; + const shimmer = 0.72 + pseudoNoise(Math.floor(t * 7) + seed) * 0.38; + const alpha = clamp((0.05 + strength * 0.18) * shimmer * (phase.darkness || 1), 0.02, 0.24); + ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`; + const rows = 1 + Math.floor(strength * 3); + for (let r = 0; r < rows; r++) { + const wobble = Math.round((pseudoNoise(seed + r * 5.9 + Math.floor(t * 4)) - 0.5) * 8); + const len = Math.max(2, Math.round((5 + strength * 10) * (r === 0 ? 1 : 0.65))); + const yy = Math.round(py + r * 3 + pseudoNoise(seed + r * 2.7) * 2); + const xx = Math.round(px + wobble - len / 2); + ctx.fillRect(xx, yy, len, 1); + if (strength > 0.55 && r === 0) ctx.fillRect(xx + Math.floor(len / 2), yy + 1, 1, 1); + } + } + } + } + ctx.restore(); + } + function drawSpriteLightReflection(info, item, lightSources, phase) { - if (!areNightLightsActive(phase) || !lightSources?.length) return; + if (visualSettings().enableLights === false || !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; @@ -3799,21 +4447,24 @@ if (!sources.length) return; sources.sort((a, b) => a.distance - b.distance); + const aw = assetWidth(asset); + const ah = assetHeight(asset); 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 rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], aw, ah); + const depths = asset.category === 'dynamic' && info.side === 'left' ? mirrorDepthPixels(rawDepths, aw, ah) : 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; + for (let y = 0; y < ah; y++) { + for (let x = 0; x < aw; x++) { + if (!pixels[y * aw + x]) continue; + const depth = depths[y * aw + x] || 0; + if (!depth) continue; let rr = 0, gg = 0, bb = 0, aa = 0; - for (const { source, local } of sources.slice(0, 3)) { + for (const { source, local } of sources) { const lc = parseHex(source.color || '#ffd86a'); if (!lc) continue; const cellPx = (x + 0.5) * scale; @@ -3822,9 +4473,12 @@ 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; + const edge = getDepthLightFacing(depth, x, y, aw, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5, ah); + const depthFactor = depth > 0 ? 0.98 + edge * 0.42 : 0.74 + edge * 0.22; + const sourceIntensity = clamp(Number(source.intensity ?? 1) || 1, 0.25, 1.75); + // Make weak light subtle and strong light visibly snap on, instead of a flat linear ramp. + const exponential = (Math.exp(3.15 * t * sourceIntensity) - 1) / (Math.exp(3.15 * sourceIntensity) - 1); + const amount = exponential * depthFactor; rr += lc.r * amount; gg += lc.g * amount; bb += lc.b * amount; @@ -3832,15 +4486,19 @@ } if (aa <= 0.002) continue; const inv = 1 / aa; - const alpha = Math.min(0.34, 0.08 + aa * 0.18); + const alpha = Math.min(0.56, 0.08 + Math.pow(aa, 1.22) * 0.34); 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); + if (aa > 0.42 && scale >= 4) { + octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${(alpha * 0.34).toFixed(3)})`; + octx.fillRect(x * scale + 1, y * scale + 1, Math.max(1, scale - 2), Math.max(1, scale - 2)); + } } } ctx.save(); ctx.globalAlpha = 1; - ctx.globalCompositeOperation = 'source-over'; + ctx.globalCompositeOperation = 'lighter'; if (info.angle) { ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); ctx.rotate(info.angle); @@ -3851,12 +4509,7 @@ 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) { +function spawnGroundStepParticles(item, time, tile) { if (Math.random() > 0.55) return; const baseColor = getTerrainSurfaceColorAt(item.x, item.y); landStepParticles.push({ @@ -3899,7 +4552,7 @@ const scale = item.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; 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 ex = item.asset.category === 'dynamic' && info.side === 'left' ? assetWidth(item.asset) - 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'); @@ -3944,23 +4597,31 @@ } - function drawShipRipples(pos, time, seedValue = '') { +function drawShipRipples(pos, time, seedValue = '', asset = null) { const seed = fnv1a(String(seedValue)).slice(0, 6); const numericSeed = parseInt(seed, 16) || 1; const cycleMs = 2300; const cycle = Math.floor((time + numericSeed) / cycleMs); if (pseudoNoise(cycle + numericSeed * 0.013) < 0.38) return; const t = ((time + numericSeed) % cycleMs) / cycleMs; - const alpha = Math.max(0, 0.36 * (1 - t)); - const rx = 5 + t * 22; - const ry = 2 + t * 8; + const shipScale = clamp((assetMaxSize(asset || {}) || 16) / 16, 1, 4); + const waveScale = 0.85 + shipScale * 0.55; + const alpha = Math.max(0, 0.34 * (1 - t)); + const rx = (5 + t * 22) * waveScale; + const ry = (2 + t * 8) * (0.85 + shipScale * 0.34); ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = '#e8fbff'; - ctx.lineWidth = Math.max(1, 1.5 / view.zoom); - ctx.beginPath(); - ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx, ry, 0, 0, Math.PI * 2); - ctx.stroke(); + ctx.lineWidth = Math.max(1, (1.25 + shipScale * 0.18) / view.zoom); + const rings = shipScale >= 2.35 ? 2 : 1; + for (let i = 0; i < rings; i++) { + const grow = i * (7 + shipScale * 3); + const fade = i ? 0.58 : 1; + ctx.globalAlpha = alpha * fade; + ctx.beginPath(); + ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx + grow, ry + grow * 0.28, 0, 0, Math.PI * 2); + ctx.stroke(); + } ctx.restore(); } @@ -3982,28 +4643,33 @@ function drawSpriteShadow(info, phase) { - const { drawX, sprite, pos, anchorY, bob, stretchX = 1, stretchY = 1, asset } = info; + const { drawX, sprite, pos, anchorY, bob, angle = 0, stretchX = 1, stretchY = 1, asset } = info; const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 }; if ((shadow.alpha || 0) <= 0.001) return; + const c = activeShadowCtx || ctx; const silhouette = getShadowCanvas(sprite); + const isShip = asset?.subtype === 'ship'; const contactX = drawX + sprite.width / 2; - const groundY = pos.y + anchorY; - const mirrorOffset = Math.max(0, -(bob || 0)); + const groundY = pos.y + anchorY + (isShip ? (bob || 0) * 0.82 : 0); + const mirrorOffset = isShip ? 0 : Math.max(0, -(bob || 0)); + const shadowAngle = isShip ? angle * 0.65 : 0; - ctx.save(); - ctx.globalAlpha = shadow.alpha; - ctx.translate(contactX, groundY); - ctx.transform((1 + shadow.length * 0.08) * stretchX, 0, shadow.skewX, shadow.scaleY * Math.max(0.88, stretchY), 0, 0); - ctx.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset)); - ctx.restore(); + c.save(); + c.globalAlpha = activeShadowCtx ? 1 : shadow.alpha; + c.translate(contactX, groundY); + if (shadowAngle) c.rotate(shadowAngle); + c.transform((1 + shadow.length * 0.08) * stretchX, 0, shadow.skewX, shadow.scaleY * Math.max(0.88, stretchY), 0, 0); + c.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset)); + c.restore(); - if (asset?.subtype === 'ship') { + if (isShip) { const reflection = getShipReflectionCanvas(sprite); ctx.save(); ctx.globalAlpha = 0.24; ctx.translate(contactX, groundY + 1); + if (shadowAngle) ctx.rotate(shadowAngle); ctx.transform(1.08 * stretchX, 0, shadow.skewX * 0.55, 0.36 * Math.max(0.92, stretchY), 0, 0); - ctx.drawImage(reflection, Math.round(-sprite.width / 2), Math.round(mirrorOffset + 2)); + ctx.drawImage(reflection, Math.round(-sprite.width / 2), 2); ctx.restore(); } } @@ -4174,16 +4840,18 @@ function getSelectedBubbleAnchor(time = performance.now()) { if (!selectedObject) return null; - const cached = lastRenderedSpriteInfo.get(selectedObject.id); - if (cached) { - const topPad = Math.max(18, Math.min(34, cached.sprite.height * 0.22)); - return { x: cached.drawX + cached.sprite.width / 2, y: cached.drawY - topPad, asset: cached.asset, objectId: selectedObject.id }; - } const item = getSelectedDrawableItem(time); - if (!item) return null; - const info = getSpriteDrawInfo(item, time, true); - const topPad = Math.max(18, Math.min(34, info.sprite.height * 0.22)); - return { x: info.drawX + info.sprite.width / 2, y: info.drawY - topPad, asset: item.asset, objectId: item.source?.id || selectedObject.id }; + if (item) { + const info = getSpriteDrawInfo(item, time, false); + const topPad = Math.max(14, Math.min(28, info.sprite.height * 0.18)); + const baseY = info.pos.y + info.anchorY - info.sprite.height; + return { x: info.drawX + info.sprite.width / 2, y: baseY - topPad, asset: item.asset, objectId: item.source?.id || selectedObject.id }; + } + const cached = lastRenderedSpriteInfo.get(selectedObject.id); + if (!cached) return null; + const topPad = Math.max(14, Math.min(28, cached.sprite.height * 0.18)); + const baseY = cached.pos && Number.isFinite(cached.anchorY) ? cached.pos.y + cached.anchorY - cached.sprite.height : cached.drawY; + return { x: cached.drawX + cached.sprite.width / 2, y: baseY - topPad, asset: cached.asset, objectId: selectedObject.id }; } function updateSelectionBubble(time) { @@ -4194,21 +4862,18 @@ if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } - 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 anchor = getSelectedBubbleAnchor(time) || { x: selected.pos.x, y: selected.pos.y - Math.max(48, assetMaxSize(selected.asset) * 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) { + const rawSx = anchor.x * view.zoom + view.x; + const rawSy = anchor.y * view.zoom + view.y; + const fixedFollowBubble = cameraFollowSelected && selectedObject; + const sx = fixedFollowBubble ? cw * 0.5 : rawSx; + const sy = fixedFollowBubble ? Math.max(92, ch * 0.32) : rawSy; + if (!fixedFollowBubble && (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; - const bubbleX = clamp(sx, 112, cw - 112); - const bubbleY = clamp(sy, 96, ch - 160); - 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; @@ -4232,7 +4897,9 @@ els.voteDown.classList.toggle('mutedVote', previous > 0); if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0; if (els.bubbleEdit) { - const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + const mine = isSharedWorld() + ? normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId() + : (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); els.bubbleEdit.hidden = !mine; } if (els.bubbleReport) { @@ -4240,6 +4907,25 @@ els.bubbleReport.disabled = reported; els.bubbleReport.textContent = reported ? 'Reported' : 'Report'; } + + els.selectionBubble.hidden = false; + els.selectionBubble.classList.remove('belowSprite'); + const rect = els.selectionBubble.getBoundingClientRect(); + const halfW = Math.max(70, (rect.width || 180) / 2); + const height = Math.max(64, rect.height || 120); + const bubbleX = clamp(sx, halfW + 8, Math.max(halfW + 8, cw - halfW - 8)); + const aboveY = sy; + const belowY = fixedFollowBubble ? sy : (selected.pos.y * view.zoom + view.y) + Math.max(36, assetMaxSize(asset) * view.zoom * 0.5); + const useBelow = !fixedFollowBubble && aboveY - height < 8; + const bubbleY = fixedFollowBubble + ? clamp(sy, height + 8, Math.max(height + 8, ch - 8)) + : useBelow + ? clamp(belowY, 8, Math.max(8, ch - height - 8)) + : clamp(aboveY, height + 8, Math.max(height + 8, ch - 8)); + els.selectionBubble.classList.toggle('belowSprite', useBelow); + els.selectionBubble.style.transform = useBelow ? 'translate(-50%, 0)' : 'translate(-50%, -100%)'; + els.selectionBubble.style.setProperty('--bubble-left', `${Math.round(bubbleX)}px`); + els.selectionBubble.style.setProperty('--bubble-top', `${Math.round(bubbleY)}px`); } function voteSelected(delta) { @@ -4282,7 +4968,9 @@ if (!selectedObject) return; const asset = findAsset(selectedObject.assetId); if (!asset) return; - const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + const mine = isSharedWorld() + ? normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId() + : (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); if (!mine) { toast('Only your own assets can be edited directly. Use Remix instead.'); return; @@ -4412,7 +5100,7 @@ const speed = 18 + pseudoNoise(i * 4.7 + time) * 34; confettiParticles.push({ x: selected.pos.x, - y: selected.pos.y - selected.asset.size * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8, + y: selected.pos.y - assetHeight(selected.asset) * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 25, color: palette[i % palette.length], @@ -4470,15 +5158,25 @@ ctx.beginPath(); ctx.arc(sx, sy, auraRadius, 0, Math.PI * 2); ctx.fill(); + + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = hexToRgba(source.color, .92); + const coreSize = Math.max(2, Math.round(2.6 * view.zoom)); + ctx.fillRect(Math.round(sx - coreSize / 2), Math.round(sy - coreSize / 2), coreSize, coreSize); + ctx.fillStyle = 'rgba(255, 250, 206, .54)'; + const hotSize = Math.max(1, Math.round(1.2 * view.zoom)); + ctx.fillRect(Math.round(sx - hotSize / 2), Math.round(sy - hotSize / 2), hotSize, hotSize); } ctx.restore(); } function getSpriteCanvas(asset, side) { const lightsOn = areNightLightsActive(renderPhase); - const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}` : 'day:unlit'; + const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}:${visualSettings().enableLights === false ? 'depthoff' : 'depthon'}` : 'day:unlit'; + const w = assetWidth(asset); + const h = assetHeight(asset); const revision = asset.contentHash || asset.updatedAt || asset.createdAt || asset.pixels || asset.faces?.right || ''; - const key = `${asset.id}:${revision}:${side}:${asset.size}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; + const key = `${asset.id}:${revision}:${side}:${w}x${h}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; if (spriteCache.has(key)) { const cached = spriteCache.get(key); spriteCache.delete(key); @@ -4486,21 +5184,22 @@ return cached; } 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 rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], w, h); + const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, w, h) : rawDepths; + const lights = []; + const depthVisualsOn = visualSettings().enableLights !== false; const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const canvas = document.createElement('canvas'); - canvas.width = asset.size * scale; - canvas.height = asset.size * scale; + canvas.width = w * scale; + canvas.height = h * scale; const c = canvas.getContext('2d'); c.imageSmoothingEnabled = false; - for (let y = 0; y < asset.size; y++) { - for (let x = 0; x < asset.size; x++) { - const color = pixels[y * asset.size + x]; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const color = pixels[y * w + x]; if (!color) continue; - const depth = depths[y * asset.size + x] || 0; - c.fillStyle = shadeAssetPixelColor(colorToHex(color), depth, x, y, asset.size, renderPhase, pixels, depths, lights); + const depth = depthVisualsOn ? (depths[y * w + x] || 0) : 0; + c.fillStyle = shadeAssetPixelColor(colorToHex(color), depth, x, y, w, renderPhase, pixels, depthVisualsOn ? depths : null, lights, h); c.fillRect(x * scale, y * scale, scale, scale); } } @@ -4517,8 +5216,10 @@ } function getAssetPixels(asset, side = 'right') { - const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size); - if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size); + const w = assetWidth(asset); + const h = assetHeight(asset); + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(w, h), w, h); + if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, w, h); return right; } @@ -4585,7 +5286,11 @@ c.imageSmoothingEnabled = false; c.save(); c.translate(-bounds.x, -bounds.y); - for (const tile of tiles) drawTerrainTile(c, tile, worldData); + const orderedTiles = tiles.slice().sort(terrainDrawCompare); + for (const tile of orderedTiles) { + if (tile.type === 'highland') drawHighlandWallFaces(c, tile, worldData); + drawTerrainTile(c, tile, worldData); + } c.restore(); chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy }); minX = Math.min(minX, bounds.x); @@ -4596,6 +5301,7 @@ } const coastTiles = findCoastFoamTiles(worldData); if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize, coastTiles }; + chunks.sort((a, b) => (a.tileX + a.tileY) - (b.tileX + b.tileY) || a.tileY - b.tileY || a.tileX - b.tileX); return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize, coastTiles }; } @@ -4617,6 +5323,10 @@ return out; } + function terrainDrawCompare(a, b) { + return (a.x + a.y) - (b.x + b.y) || a.y - b.y || a.x - b.x; + } + function getTerrainChunkBounds(tiles) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const tile of tiles) { @@ -4642,50 +5352,6 @@ } } - function buildTerrainFrontCache(worldData) { - const chunkSize = TERRAIN_CHUNK_SIZE; - const chunks = []; - for (let cy = 0; cy < WORLD_H; cy += chunkSize) { - for (let cx = 0; cx < WORLD_W; cx += chunkSize) { - const tiles = []; - for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) { - for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) { - const tile = worldData.get(x, y); - if (!tile || tile.type !== 'highland') continue; - const frontLeft = worldData.get(tile.x, tile.y + 1); - const frontRight = worldData.get(tile.x + 1, tile.y); - const leftLower = !frontLeft || frontLeft.type !== 'highland'; - const rightLower = !frontRight || frontRight.type !== 'highland'; - if (leftLower || rightLower) tiles.push(tile); - } - } - if (!tiles.length) continue; - const bounds = getTerrainChunkBounds(tiles); - const canvas = document.createElement('canvas'); - canvas.width = Math.ceil(bounds.w); - canvas.height = Math.ceil(bounds.h); - const c = canvas.getContext('2d', { alpha: true }); - c.imageSmoothingEnabled = false; - c.save(); - c.translate(-bounds.x, -bounds.y); - for (const tile of tiles) drawTerrainFrontTile(c, tile, worldData); - c.restore(); - chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height }); - } - } - return { chunks }; - } - - function drawTerrainFrontCache(cache) { - if (!cache?.chunks?.length) return; - const rect = getViewportWorldRect(64); - for (const chunk of cache.chunks) { - if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue; - ctx.drawImage(chunk.canvas, chunk.x, chunk.y); - } - } - - function buildCoastalFoamTextures() { return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) }; } @@ -4742,35 +5408,6 @@ } - function drawTerrainFrontTile(c, tile, worldData) { - const { x, y } = tileToWorld(tile.x, tile.y); - const lift = getTileLift(tile); - const frontLeft = worldData.get(tile.x, tile.y + 1); - const frontRight = worldData.get(tile.x + 1, tile.y); - const leftLower = !frontLeft || frontLeft.type !== 'highland'; - const rightLower = !frontRight || frontRight.type !== 'highland'; - if (rightLower) { - c.fillStyle = '#7b8e6c'; - c.beginPath(); - c.moveTo(x, y + TILE_H - lift); - c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); - c.lineTo(x + TILE_W / 2, y + TILE_H / 2); - c.lineTo(x, y + TILE_H); - c.closePath(); - c.fill(); - } - if (leftLower) { - c.fillStyle = '#6f8263'; - c.beginPath(); - c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift); - c.lineTo(x, y + TILE_H - lift); - c.lineTo(x, y + TILE_H); - c.lineTo(x - TILE_W / 2, y + TILE_H / 2); - c.closePath(); - c.fill(); - } - } - function drawTerrainTile(c, tile, worldData) { const { x, y } = tileToWorld(tile.x, tile.y); const palette = { @@ -4792,6 +5429,10 @@ c.closePath(); c.fill(); + if (tile.type !== 'water') { + drawTerrainAmbientOcclusion(c, tile, worldData, x, y, lift); + } + if (tile.type === 'water') { c.fillStyle = 'rgba(255,255,255,.045)'; c.beginPath(); @@ -4814,6 +5455,40 @@ c.stroke(); } + + function drawTerrainAmbientOcclusion(c, tile, worldData, x, y, lift) { + // Terrain AO linework removed: it produced horizontal artifacts on highland tiles. + } + + function drawHighlandWallFaces(c, tile, worldData) { + const { x, y } = tileToWorld(tile.x, tile.y); + const lift = getTileLift(tile); + const frontLeft = worldData.get(tile.x, tile.y + 1); + const frontRight = worldData.get(tile.x + 1, tile.y); + const leftLower = !frontLeft || frontLeft.type !== 'highland'; + const rightLower = !frontRight || frontRight.type !== 'highland'; + if (rightLower) { + c.fillStyle = '#7f8b66'; + c.beginPath(); + c.moveTo(x, y + TILE_H - lift); + c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); + c.lineTo(x + TILE_W / 2, y + TILE_H / 2); + c.lineTo(x, y + TILE_H); + c.closePath(); + c.fill(); + } + if (leftLower) { + c.fillStyle = '#6f7d5d'; + c.beginPath(); + c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift); + c.lineTo(x, y + TILE_H - lift); + c.lineTo(x, y + TILE_H); + c.lineTo(x - TILE_W / 2, y + TILE_H / 2); + c.closePath(); + c.fill(); + } + } + function getTileLift(tile) { return tile?.type === 'highland' ? 12 : 0; } @@ -4863,6 +5538,7 @@ function saveState() { state.schema = SAVE_SCHEMA; + ensureWorldProtectionState(); canonicalizeAssetStorage(); state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) }; state.moderationReports = normalizeModerationReports(state.moderationReports || []); @@ -4876,11 +5552,27 @@ updateRotationStats(); } + function dedupeNormalizedAssets(assets) { + const byId = new Map(); + const exact = new Set(); + const out = []; + for (const asset of assets || []) { + if (!asset?.id) continue; + if (byId.has(asset.id)) continue; + const key = [asset.contentHash || '', asset.name || '', asset.category || '', asset.subtype || '', asset.width || asset.size || '', asset.height || asset.size || '', normalizeOwnerAccountId(asset.ownerAccountId)].join('|'); + if (asset.contentHash && exact.has(key)) continue; + byId.set(asset.id, asset); + if (asset.contentHash) exact.add(key); + out.push(asset); + } + return out; + } + function normalizeState(input) { - return { + const normalized = { schema: SAVE_SCHEMA, authorName: input.authorName || 'Local Artist', - assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [], + assets: dedupeNormalizedAssets(Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : []), placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []), dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []), objectVotes: input.objectVotes || {}, @@ -4893,8 +5585,19 @@ sync: input.sync || { lastEventId: null }, settings: { ...defaultVisualSettings(), ...(input.settings || {}) }, account: normalizeAccount(input.account), - publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [] + publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [], + worldMode: input.worldMode === 'shared' ? 'shared' : 'local', + serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] }, + tombstones: input.tombstones || { assets: {}, objects: {} } }; + ensureWorldProtectionState(normalized); + const ownerByAssetId = new Map(normalized.assets.map((asset) => [asset.id, normalizeOwnerAccountId(asset.ownerAccountId)])); + normalized.placed.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); }); + normalized.dynamicSummons.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); }); + normalized.assets = normalized.assets.filter((asset) => !normalized.tombstones.assets?.[asset.id]); + normalized.placed = normalized.placed.filter((item) => !normalized.tombstones.objects?.[item.id]); + normalized.dynamicSummons = normalized.dynamicSummons.filter((item) => !normalized.tombstones.objects?.[item.id]); + return normalized; } function canonicalizeAssetStorage() { @@ -4910,9 +5613,11 @@ } function normalizeAsset(asset) { - const size = Number(asset.size) || 16; + const width = assetWidth(asset); + const height = assetHeight(asset); + const size = Math.max(width, height); const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; - const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size); + const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(width, height), width, height); const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other'); const normalized = { id: asset.id || uid(), @@ -4920,51 +5625,55 @@ category, subtype, size, - pixels: encodePixels(right), - faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null, + width, + height, + pixels: encodePixels(right, width, height), + faces: category === 'dynamic' ? { right: encodePixels(right, width, height), left: 'mirror' } : null, parentAssetId: asset.parentAssetId || null, originalAssetId: asset.originalAssetId || null, createdAt: asset.createdAt || Date.now(), updatedAt: asset.updatedAt || asset.createdAt || Date.now(), author: asset.author || 'Local Artist', - meta: normalizeAssetMeta(asset.meta || {}, category, subtype, size) + ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, asset.accountId || asset.authorId || ''), + version: Number(asset.version) || 1, + meta: normalizeAssetMeta(asset.meta || {}, category, subtype, width, height, right) }; normalized.contentHash = computeAssetContentHash(normalized); return normalized; } - function normalizeAssetMeta(meta, category, subtype, size) { - const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size); + function normalizeAssetMeta(meta, category, subtype, width, height = width, sourcePixels = null) { + const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], width, height); const lightPixels = Array.isArray(meta.lightPixels) ? meta.lightPixels - .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') })) + .map((p) => ({ x: clampInt(p.x, 0, width - 1, 0), y: clampInt(p.y, 0, height - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; 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'), dir: p.dir || 'up' })) + .map((p) => ({ x: clampInt(p.x, 0, width - 1, 0), y: clampInt(p.y, 0, height - 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, legacyParticlePixels); + return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, width, legacyParticlePixels, sourcePixels, height); } - function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticle = null) { - const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); + function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, width = 8, sourceParticle = null, sourcePixels = null, height = width) { + const w = clampDimension(width, 8); + const h = clampDimension(height, w); + const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], w, h); const hasDepth = normalizedDepth.some(Boolean); - const lightPixelsClean = Array.isArray(sourceLightPixels) - ? sourceLightPixels - .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 pixelSource = sourcePixels ? normalizePixels(sourcePixels, w, h) : null; + const lightPixelsClean = sanitizeLightPixels(sourceLightPixels, pixelSource || Array(w * h).fill('x'), w, w, h, lightColor || '#ffd86a', false) + .map((p) => ({ x: p.x, y: p.y, c: PALETTE_BY_CODE[p.c] ? p.c : nearestPaletteCode(lightColor || '#ffd86a') })); 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)) + .map((p) => ({ x: clampInt(p.x, 0, w - 1, 0), y: clampInt(p.y, 0, h - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) + .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!pixelSource || pixelSource[p.y * w + p.x])) : []; 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 particleClean = normalizeParticleConfig(particleSource || { enabled: false, c: nearestPaletteCode('#ffffff'), dir: 'up' }, Math.max(w, h)); 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 = particleCells.length > 0; @@ -4976,7 +5685,7 @@ 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 + door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, w - 1, Math.floor(w / 2)), y: clampInt(door.y, 0, h - 1, h - 1) } : null }; } @@ -4989,6 +5698,7 @@ placedAt: item.placedAt || item.createdAt || Date.now(), publishedAt: item.publishedAt || item.placedAt || item.createdAt || Date.now(), status: item.status || 'active', + ownerAccountId: normalizeOwnerAccountId(item.ownerAccountId), version: Number(item.version) || 1 })).filter((item) => item.assetId); } @@ -5002,6 +5712,7 @@ createdAt: item.createdAt || item.placedAt || Date.now(), publishedAt: item.publishedAt || item.createdAt || item.placedAt || Date.now(), status: item.status || 'active', + ownerAccountId: normalizeOwnerAccountId(item.ownerAccountId), version: Number(item.version) || 1 })).filter((item) => item.assetId); } @@ -5056,10 +5767,10 @@ authorName: 'Local Artist', assets, placed: [ - { id: uid(), assetId: idByName['Hill Cottage'], x: 36, y: 36, placedAt: Date.now(), version: 1 }, - { id: uid(), assetId: idByName['Pine Cluster'], x: 32, y: 36, placedAt: Date.now(), version: 1 }, - { id: uid(), assetId: idByName['Shell Rock'], x: 39, y: 40, placedAt: Date.now(), version: 1 }, - { id: uid(), assetId: idByName['Wave Skiff'], ...shipPos, placedAt: Date.now(), version: 1 } + { id: uid(), assetId: idByName['Hill Cottage'], ownerAccountId: 'island-team', x: 36, y: 36, placedAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Pine Cluster'], ownerAccountId: 'island-team', x: 32, y: 36, placedAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Shell Rock'], ownerAccountId: 'island-team', x: 39, y: 40, placedAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Wave Skiff'], ownerAccountId: 'island-team', ...shipPos, placedAt: Date.now(), version: 1 } ], objectVotes: {}, assetVotes: {}, @@ -5070,11 +5781,14 @@ settings: defaultVisualSettings(), account: null, publishLog: [], + worldMode: 'local', + serverSync: { lastServerEventId: null, pendingCommands: [] }, + tombstones: { assets: {}, objects: {} }, dynamicSummons: [ - { id: uid(), assetId: idByName['Fisher Kid'], homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 }, - { id: uid(), assetId: idByName['Moss Cat'], homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 }, - { id: uid(), assetId: idByName['Koi Fish'], ...homeFromPos(fishPos), createdAt: Date.now(), version: 1 }, - { id: uid(), assetId: idByName['Cloud Gull'], homeX: 90, homeY: 30, createdAt: Date.now(), version: 1 } + { id: uid(), assetId: idByName['Fisher Kid'], ownerAccountId: 'island-team', homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Moss Cat'], ownerAccountId: 'island-team', homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Koi Fish'], ownerAccountId: 'island-team', ...homeFromPos(fishPos), createdAt: Date.now(), version: 1 }, + { id: uid(), assetId: idByName['Cloud Gull'], ownerAccountId: 'island-team', homeX: 90, homeY: 30, createdAt: Date.now(), version: 1 } ] }; } @@ -5096,7 +5810,9 @@ 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.particleConfig || meta.particlePixels || []) + ownerAccountId: 'island-team', + version: 1, + meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particleConfig || meta.particlePixels || [], right, size) }; asset.contentHash = computeAssetContentHash(asset); return asset; @@ -5152,6 +5868,31 @@ toast('Asset bundle JSON exported.'); } + function prepareImportedAssetForSharedWorld(asset) { + const actor = currentAccountId(); + const existing = state.assets.find((item) => item.id === asset.id); + const existingOwner = normalizeOwnerAccountId(existing?.ownerAccountId); + if (existing && existingOwner && existingOwner !== actor) { + return { + ...asset, + id: uid(), + ownerAccountId: actor, + author: state.authorName || actor || 'Local Artist', + parentAssetId: asset.parentAssetId || asset.id, + originalAssetId: asset.originalAssetId || asset.id, + createdAt: Date.now(), + updatedAt: Date.now(), + version: 1 + }; + } + return { + ...asset, + ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, actor), + author: asset.author || state.authorName || actor || 'Local Artist', + version: Number(asset.version) || 1 + }; + } + function importData() { try { const imported = JSON.parse(els.dataBox.value); @@ -5160,7 +5901,11 @@ return; } if (Phase2Sync?.isAssetBundle?.(imported)) { - const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset); + if (isSharedWorld()) ensureLocalAccount('import'); + const importedAssets = Phase2Sync.unpackAssetBundle(imported) + .map(normalizeAsset) + .map((asset) => isSharedWorld() ? normalizeAsset(prepareImportedAssetForSharedWorld(asset)) : asset) + .filter((asset) => !isAssetTombstoned(asset.id, asset.version)); const byId = new Map(state.assets.map((asset) => [asset.id, asset])); importedAssets.forEach((asset) => byId.set(asset.id, asset)); state.assets = [...byId.values()]; @@ -5172,6 +5917,10 @@ toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`); return; } + if (isSharedWorld()) { + toast('Shared worlds block full-state imports. Import an asset bundle or use a local sandbox.'); + return; + } const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported; state = normalizeState(expanded); rebuildWorldIndex(); @@ -5206,6 +5955,11 @@ function recordSyncEvent(event) { if (!event) return; + if (isSharedWorld()) { + const command = sharedCommandFromLocalEvent(event); + if (command) queueSharedCommand(command); + return; + } state.eventLog ||= []; state.eventLog.push(event); state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)); @@ -5213,12 +5967,31 @@ state.sync.lastEventId = event.id; } + function sharedCommandFromLocalEvent(event) { + if (!event?.type) return null; + if (event.type === 'asset.upsert' && event.asset) return makeSharedCommand('asset.create', { asset: Phase2Sync?.unpackAsset ? Phase2Sync.unpackAsset(event.asset) : event.asset }); + if (event.type === 'asset.delete' && event.assetId) return makeSharedCommand('asset.delete', { assetId: event.assetId }); + if (event.type === 'object.upsert' && event.object) { + const object = event.kind === 'dynamic' + ? Phase2Sync?.unpackDynamic?.(event.object) || event.object + : Phase2Sync?.unpackPlacement?.(event.object) || event.object; + return makeSharedCommand('object.publish', { kind: event.kind === 'dynamic' ? 'dynamic' : 'static', object }); + } + if (event.type === 'object.delete' && event.objectId) return makeSharedCommand('object.delete', { kind: event.kind === 'dynamic' ? 'dynamic' : 'static', objectId: event.objectId }); + return null; + } + function applySyncEvent(event) { if (!event) return false; + if (isSharedWorld() && !event.serverEventId) { + console.warn('Rejected non-server sync event in shared world.', event); + return false; + } const unpackObject = (kind, object) => kind === 'dynamic' ? Phase2Sync?.unpackDynamic?.(object) || object : Phase2Sync?.unpackPlacement?.(object) || object; - if (StateIndex?.reduce) StateIndex.reduce(state, event, { unpackAsset: Phase2Sync?.unpackAsset, unpackObject }); + if (isSharedWorld()) applyServerIssuedEvent(event, unpackObject); + else if (StateIndex?.reduce) StateIndex.reduce(state, event, { unpackAsset: Phase2Sync?.unpackAsset, unpackObject }); else Phase2Sync?.applyEvent?.(state, event); rebuildWorldIndex(); spriteCache.clear(); @@ -5227,6 +6000,52 @@ return true; } + function applyServerIssuedEvent(event, unpackObject) { + ensureWorldProtectionState(); + if (event.type === 'asset.upsert' && event.asset) { + const asset = event.asset.p && Phase2Sync?.unpackAsset ? Phase2Sync.unpackAsset(event.asset) : event.asset; + if (!asset?.id || isAssetTombstoned(asset.id, asset.version || event.assetVersion || 1)) return; + const index = state.assets.findIndex((item) => item.id === asset.id); + if (index >= 0) state.assets[index] = normalizeAsset(asset); + else state.assets.unshift(normalizeAsset(asset)); + } else if (event.type === 'object.upsert' && event.object) { + const kind = event.kind === 'dynamic' ? 'dynamic' : 'static'; + const object = unpackObject(kind, event.object); + if (!object?.id || isObjectTombstoned(object.id, object.version || event.objectVersion || 1)) return; + const list = kind === 'dynamic' ? state.dynamicSummons : state.placed; + const normalized = kind === 'dynamic' ? normalizeDynamicSummons([object])[0] : normalizePlacements([object])[0]; + const index = list.findIndex((item) => item.id === normalized.id); + if (index >= 0) list[index] = normalized; + else list.push(normalized); + } else if (event.type === 'object.delete' && event.objectId) { + const kind = event.kind === 'dynamic' ? 'dynamic' : 'static'; + const tombstone = event.tombstone || { id: event.objectId, deletedAt: event.serverAt, deletedBy: event.actorAccountId, version: event.objectVersion || 1 }; + state.tombstones.objects[event.objectId] = tombstone; + if (kind === 'dynamic') state.dynamicSummons = state.dynamicSummons.filter((item) => item.id !== event.objectId); + else state.placed = state.placed.filter((item) => item.id !== event.objectId); + delete state.objectVotes?.[event.objectId]; + delete state.hiddenObjects?.[event.objectId]; + state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== event.objectId); + } else if (event.type === 'asset.delete' && event.assetId) { + state.tombstones.assets[event.assetId] = event.tombstone || { id: event.assetId, deletedAt: event.serverAt, deletedBy: event.actorAccountId, version: event.assetVersion || 1 }; + const removedObjectIds = new Set((event.objectTombstones || []).map((tombstone) => { + if (tombstone?.id) state.tombstones.objects[tombstone.id] = tombstone; + return tombstone?.id; + }).filter(Boolean)); + state.assets = state.assets.filter((asset) => asset.id !== event.assetId); + state.placed = state.placed.filter((item) => !removedObjectIds.has(item.id)); + state.dynamicSummons = state.dynamicSummons.filter((item) => !removedObjectIds.has(item.id)); + delete state.assetVotes?.[event.assetId]; + delete state.hiddenAssets?.[event.assetId]; + for (const id of removedObjectIds) { + delete state.objectVotes?.[id]; + delete state.hiddenObjects?.[id]; + } + state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); + } + state.serverSync.lastServerEventId = event.serverEventId || state.serverSync.lastServerEventId; + } + function cachePhase2State() { if (!Phase2Sync?.cacheAssets) return; Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); @@ -5365,6 +6184,8 @@ asset.category || '', asset.subtype || '', asset.size || '', + asset.width || '', + asset.height || '', asset.pixels || '', asset.faces?.right || '', asset.faces?.left || '', @@ -5377,6 +6198,19 @@ return `fnv1a:${fnv1a(payload)}`; } + function findEquivalentCollectionAsset(asset) { + if (!asset?.contentHash) return null; + const owner = normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()); + return (state.assets || []).find((item) => item.id !== asset.id + && item.contentHash === asset.contentHash + && item.name === asset.name + && item.category === asset.category + && item.subtype === asset.subtype + && assetWidth(item) === assetWidth(asset) + && assetHeight(item) === assetHeight(asset) + && normalizeOwnerAccountId(item.ownerAccountId, currentAccountId()) === owner) || null; + } + function fnv1a(value) { let hash = 0x811c9dc5; for (let i = 0; i < value.length; i++) { @@ -5395,12 +6229,12 @@ return worldIndex.assetById?.get(id) || state.assets.find((asset) => asset.id === id) || null; } - function blankPixels(size) { - return Array(size * size).fill(null); + function blankPixels(width, height = width) { + return Array(Math.max(1, width) * Math.max(1, height)).fill(null); } - function normalizePixels(pixels, size) { - const out = blankPixels(size); + function normalizePixels(pixels, width, height = width) { + const out = blankPixels(width, height); if (typeof pixels === 'string') { for (let i = 0; i < Math.min(out.length, pixels.length); i++) { const ch = pixels[i]; @@ -5418,8 +6252,10 @@ return out; } - function encodePixels(pixels) { - return normalizePixels(pixels, Math.sqrt(pixels.length) || editorSize).map((value) => value || '.').join(''); + function encodePixels(pixels, width = null, height = null) { + const w = width || Math.sqrt(pixels?.length || 0) || editorSize; + const h = height || w; + return normalizePixels(pixels, w, h).map((value) => value || '.').join(''); } function colorToHex(value) { @@ -5460,8 +6296,8 @@ - function normalizeDepthPixels(input, size) { - const out = Array(size * size).fill(0); + function normalizeDepthPixels(input, width, height = width) { + const out = Array(Math.max(1, width) * Math.max(1, height)).fill(0); if (typeof input === 'string') { for (let i = 0; i < Math.min(out.length, input.length); i++) { const ch = input[i]; @@ -5497,29 +6333,33 @@ } function getAssetDepth(asset, x, y, side = 'right') { - const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); - const sx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - x : x; - return depth[y * asset.size + sx] || 0; + const w = assetWidth(asset); + const h = assetHeight(asset); + const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], w, h); + const sx = asset.category === 'dynamic' && side === 'left' ? w - 1 - x : x; + return depth[y * w + sx] || 0; } function applyDepthToColor(hex, depth) { - return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase); + return shadeAssetPixelColor(hex, visualSettings().enableLights === false ? 0 : depth, 0, 0, 1, renderPhase); } - function shadeAssetPixelColor(hex, depth, x, y, size, phase, pixels = null, depths = null, lights = []) { + function shadeAssetPixelColor(hex, depth, x, y, width, phase, pixels = null, depths = null, lights = [], height = width) { 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' ? 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; + const size = Math.max(width, height); + const vertical = 1 - (y / Math.max(1, height - 1)); + const horizontal = ((x / Math.max(1, width - 1)) - 0.5) * dirX; + const solarDepth = (activePhase?.key === 'night' ? 3.0 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 8.5 : 12.0); + const depthBoost = depth * solarDepth * 0.35; + const exposure = (vertical * 0.62 + horizontal * 0.5) * (activePhase?.key === 'night' ? 3.8 : 10); + const edgeBoost = getDepthEdgeLightBoost(depth, x, y, width, pixels, depths, lights, activePhase, height); + const selfShadow = getDepthCellSelfShadow(depth, x, y, width, pixels, depths, activePhase, height); + let r = rgb.r + depthBoost + exposure + edgeBoost + selfShadow; + let g = rgb.g + depthBoost + exposure + edgeBoost + selfShadow; + let b = rgb.b + depthBoost + exposure + edgeBoost + selfShadow; if (areNightLightsActive(activePhase) && lights?.length) { for (const light of lights) { @@ -5529,70 +6369,113 @@ 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)); + const edge = getDepthLightFacing(depth, x, y, width, pixels, depths, light.x, light.y, height); + const depthFactor = depth > 0 ? 0.72 + edge * 0.24 : depth < 0 ? 0.55 + edge * 0.12 : 0.42 + edge * 0.06; + const strength = t * t * (6 + Math.abs(depth) * 4) * depthFactor; + const mix = Math.min(0.34, 0.08 + t * (depth ? 0.16 : 0.12)); 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); + const quantized = quantizePixelShade(rgb, { r, g, b }, activePhase, depth); + r = clamp(quantized.r, 0, 255); + g = clamp(quantized.g, 0, 255); + b = clamp(quantized.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; + + + function quantizePixelShade(base, shaded, phase, depth = 0) { + // Pixel-art lighting: use 11 discrete bands (-5..+5). The old -2/+2 endpoints + // map to the new -5/+5 endpoints, so maximum contrast is preserved. + const delta = ((shaded.r + shaded.g + shaded.b) - (base.r + base.g + base.b)) / 3; + const nightBias = phase?.key === 'night' ? -1.12 : phase?.key === 'evening' || phase?.key === 'morning' ? -0.38 : 0; + let level = Math.round(delta / 7.2); + level = clamp(level + (depth > 0 ? 0.45 : depth < 0 ? -0.45 : 0) + nightBias, -5, 5); + const amount = [-34, -27, -21, -16, -8, 0, 9, 18, 24, 29, 34][Math.round(level) + 5] || 0; + const tintStrength = Math.abs(level) / 5; + const tint = phase?.key === 'night' + ? { r: -10 * tintStrength, g: -6 * tintStrength, b: 12 * tintStrength } + : phase?.key === 'evening' + ? { r: 12 * tintStrength, g: 2 * tintStrength, b: -8 * tintStrength } + : { r: 0, g: 0, b: 0 }; + return { + r: base.r + amount + tint.r, + g: base.g + amount + tint.g, + b: base.b + amount + tint.b + }; + } + + function mirrorDepthPixels(depths, width, height = width) { + const source = normalizeDepthPixels(depths || [], width, height); + const out = Array(width * height).fill(0); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) out[y * width + (width - 1 - x)] = source[y * width + x] || 0; } return out; } function getAssetLightPointsForSide(asset, side = 'right') { - const size = asset.size || 1; + const w = assetWidth(asset); + const h = assetHeight(asset); 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, + x: asset.category === 'dynamic' && side === 'left' ? w - 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)); + })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < w && p.y < h); } - function isObjectOutlinePixel(pixels, x, y, size) { - if (!pixels || !pixels[y * size + x]) return false; + function isObjectOutlinePixel(pixels, x, y, width, height = width) { + if (!pixels || !pixels[y * width + 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; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) return true; + if (!pixels[ny * width + nx]) return true; } return false; } - function collectDepthEdgeDirs(x, y, size, pixels, depths, depth) { + function collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height = width) { 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; + const neighborInside = nx >= 0 && ny >= 0 && nx < width && ny < height; + const neighborOpaque = neighborInside && pixels[ny * width + nx]; + const neighborDepth = neighborOpaque ? (depths[ny * width + nx] || 0) : 0; if (!neighborOpaque || neighborDepth !== depth) edgeDirs.push([dx, dy]); } return edgeDirs; } - function getDepthLightFacing(depth, x, y, size, pixels, depths, lightX, lightY) { + function getDepthCellSelfShadow(depth, x, y, width, pixels, depths, phase, height = width) { if (!depth || !pixels || !depths) return 0; - const edgeDirs = collectDepthEdgeDirs(x, y, size, pixels, depths, depth); + const edgeDirs = collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height); + if (!edgeDirs.length) return depth < 0 ? -5 : 0; + const sunX = clamp(-(phase?.shadow?.dirX || -0.45), -1, 1); + const sunY = -0.72; + let highlight = 0; + let shade = 0; + for (const [dx, dy] of edgeDirs) { + const dot = dx * sunX + dy * sunY; + if (dot > 0.34) highlight = Math.max(highlight, dot); + if (dot < -0.18) shade = Math.max(shade, -dot); + if (dy > 0) shade = Math.max(shade, 0.42); + } + if (depth > 0) return highlight * 9 - shade * 15; + return -8 - shade * 8 + highlight * 3; + } + + function getDepthLightFacing(depth, x, y, width, pixels, depths, lightX, lightY, height = width) { + if (!depth || !pixels || !depths) return 0; + const edgeDirs = collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height); if (!edgeDirs.length) return 0; const lx = lightX - x; const ly = lightY - y; @@ -5603,7 +6486,7 @@ return depth > 0 ? Math.max(0, bestDot) : Math.max(0, -bestDot); } - function getDepthEdgeLightBoost(depth, x, y, size, pixels, depths, lights, phase) { + function getDepthEdgeLightBoost(depth, x, y, width, pixels, depths, lights, phase, height = width) { if (!depth || !pixels || !depths || !areNightLightsActive(phase) || !lights?.length) return 0; let best = null, bestD = Infinity; for (const light of lights) { @@ -5611,13 +6494,13 @@ if (d < bestD) { bestD = d; best = light; } } if (!best) return 0; - const facing = getDepthLightFacing(depth, x, y, size, pixels, depths, best.x, best.y); + const facing = getDepthLightFacing(depth, x, y, width, pixels, depths, best.x, best.y, height); if (!facing) return 0; - const outerEdge = collectDepthEdgeDirs(x, y, size, pixels, depths, depth).some(([dx, dy]) => { + const outerEdge = collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height).some(([dx, dy]) => { const nx = x + dx, ny = y + dy; - return nx < 0 || ny < 0 || nx >= size || ny >= size || !pixels[ny * size + nx]; + return nx < 0 || ny < 0 || nx >= width || ny >= height || !pixels[ny * width + nx]; }); - const distanceFalloff = 1 - clamp(bestD / Math.max(2.25, size * 0.44), 0, 1); + const distanceFalloff = 1 - clamp(bestD / Math.max(2.25, Math.max(width, height) * 0.44), 0, 1); if (distanceFalloff <= 0) return 0; const strength = outerEdge ? 0.64 : 1; return facing * strength * distanceFalloff * distanceFalloff * (depth > 0 ? 20 : 14); @@ -5637,11 +6520,12 @@ return out; } - function mirrorPixels(pixels, size) { - const out = blankPixels(size); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - out[y * size + (size - 1 - x)] = pixels[y * size + x] || null; + function mirrorPixels(pixels, width, height = width) { + const source = normalizePixels(pixels, width, height); + const out = blankPixels(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + out[y * width + (width - 1 - x)] = source[y * width + x] || null; } } return out; @@ -5706,14 +6590,20 @@ function buildPalette() { - const neutrals = ['#fffdf7','#f3ead8','#dfd2bc','#c2b39d','#9b9083','#776d67','#5a5454','#403d42','#2d2d35','#14151c']; - const hues = [0, 24, 48, 72, 96, 132, 168, 204, 228, 264, 288, 324, 348]; - const lights = [72, 58, 46, 34]; + // Ten-column visual gradient: neutrals across the top, hue columns beneath. + // Keep all 62 legacy codes valid; the final two accents stay inside the grid. + const neutrals = ['#fffdf7','#f4ecd9','#e4d8c4','#c9baa2','#a69884','#817568','#625a54','#464248','#30303a','#151720']; + const hues = [356, 24, 50, 82, 122, 154, 188, 222, 266, 318]; + const lights = [82, 70, 58, 46, 34]; const colors = [...neutrals]; for (const light of lights) { - for (const hue of hues) colors.push(hslToHex(hue, 72, light)); + for (const hue of hues) { + const saturation = light > 76 ? 78 : light > 62 ? 76 : light > 48 ? 74 : 70; + colors.push(hslToHex(hue, saturation, light)); + } } - return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] })); + colors.push('#ff7ab3', '#79f0ff'); + return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] || '#14151c' })); } function pseudoNoise(value) { diff --git a/index.html b/index.html index ba16f62..28de878 100644 --- a/index.html +++ b/index.html @@ -9,9 +9,7 @@