From a05c03107401e9f7538713f91d6c8d13d93d80b2 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 16:26:44 +0900 Subject: [PATCH 1/6] update --- README.md | 88 +- app.js | 1245 +++++++++++++---- index.html | 135 +- js/phase2-sync.js | 256 +++- .../compression_lab.cpython-313.pyc | Bin 2591 -> 2515 bytes .../rotation_worker.cpython-313.pyc | Bin 20739 -> 20663 bytes styles.css | 444 ++++++ 7 files changed, 1782 insertions(+), 386 deletions(-) diff --git a/README.md b/README.md index 1e0dc74..7336ed8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Open `index.html` in a modern browser. No build step or third-party dependency i ## Current prototype scope - Draw pixel assets in the in-browser editor. -- Save assets to the local library. +- Save assets to the local collection. - Place static assets and summon dynamic assets onto the island. - Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only. - Local voting, hiding, remix/copy edit, and import/export tools are included. @@ -161,12 +161,12 @@ This build does not call a server from the browser. The island display cap is ap This build separates **Asset** and **PlacementObject** more explicitly. - Asset: a durable library work. Remix only copies pixels and lineage into the canvas. -- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Library. +- PlacementObject: a temporary island exhibition object. It can rotate out while the Asset remains in Collection. - Edit has been removed. To revise a work, remix/copy it, save a new Asset, then delete the old one if desired. - Publishing requires a prototype local account. The first 24 hours allow 5 public placements/hour; day 2+ allows 10/hour. - The island exhibition has 250 visible slots: 150 newest slots and 100 random revival slots. - Upvote rank effect is capped at +50 votes. -- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Library. +- Capacity rotation and extreme downvote rotation use the same user-facing explanation: the island exhibition is full, but the work remains in Collection. - Server moderation can mark `permanent_hidden` / `violation_hidden`; these are hidden from authors and viewers. Admins can restore them with the Python worker. - Particles are disabled offscreen/when zoomed out and capped at 200 active particles. @@ -187,3 +187,85 @@ python server/compression_lab.py exported_world.json ``` Use this to compare minified JSON, gzip, zlib, and Brotli when available. Production should use the compact asset codec plus HTTP gzip/Brotli rather than hand-rolled custom compression first. + +## Phase 5 Exhibition UI polish follow-up +- Compressed the account/name card so the top HUD uses less vertical space. +- Removed the right-side Pan / Place / Erase HUD; placement is guided through Save + Place and Library actions. +- Renamed the liked codex label to Favorite. +- Removed the Advanced Draw depth Clear button; depth can still be cleared with Shift/right-click. +- Normal Erase now removes Light / Particle / Depth metadata on the first click and removes the pixel color on the next click. +- Light rendering and nearby glow are night-only, with subtle random flicker. +- Remix previews hide the nearby source work while checking placement, and final placement nudges away from the remix source to avoid doubled sprites. +- Depth edge shading now reacts to configured light positions or the current sun/moon direction; object silhouette edges are excluded. + +## v8 polish notes + +- `Save + Place on Island` now auto-generates a local account when needed. The generated account stores an ID, editable display name, and generated password in local storage; users are prompted to change the password. +- `Check on Island` and `New` were removed from the finish area. Saved works enter an island placement preview with `Place here` and `Back to canvas`. +- Editor Select now clears with right-click. The island also shows the left-click/right-click selection hint. +- Basic draw buttons keep a stable layout even when Advanced is open; advanced tools are placed below them. +- Draw tools now have distinct colors and compact icons. +- Particle editing no longer paints emitter positions. Particle effects emit from the whole sprite, with palette-based color and up/down/left/right direction. + + +## v9 interaction polish and bug fixes + +- Removed automatic remix-source avoidance: remix children can now be placed near their source without being forced to a different tile. +- Animated coastal foam with drift, bob, scale, and opacity pulses. +- Removed Collection/Create buttons from the Pixel Island title bar and added a compact cute menu button for the left drawer. +- Added a confirmation dialog before clearing the entire canvas. +- Palette swatches now handle pointerdown directly so the first palette click is applied reliably. +- Particle right-click no longer disables the particle effect; right-click pans/does nothing, while Shift-click can disable. +- Added particle range controls: select an area, then press “Use Sel”; “Whole” resets emission to the whole sprite. +- Swapped Particle and Depth button order. +- Left/Right canvas side buttons no longer mirror the editing canvas view. +- Fixed dynamic animal placement preview so the sprite is visible before pressing Place here. + +### v10 interaction / lighting polish + +- Replaced the title-side menu control with a larger bottom `DRAW` button. +- Warmed the evening phase with orange/vermilion sky tint and warm darkness overlay. +- Increased lamp reflection bleed radius and light radius so nearby sprites glow more softly. +- Dynamic sprites now save a canvas marked `Left` by mirroring it into the canonical right-facing asset; runtime facing is driven by the actual movement vector. +- Hidden particle range overlays no longer look like an undeletable blue canvas square; the range overlay is shown only while the Particle tool is active. +- Remix actions are now displayed as a bottom sheet. Report is inside a small Menu section. +- Added teleport-to-remix-source from the selected object sheet. +- Object draw order now uses the rendered bottom/contact line, so lower objects consistently draw in front. +- 8×8 sprites use their whole sprite bounding box for click selection, including transparent cells. +- Coastal foam motion was made more visible. + + +## v12 lighting/compression notes + +- Night light reflections are now rendered per sprite immediately after the sprite, preserving front/back draw order. +- Reflection tint affects both depth and non-depth pixels; depth pixels receive stronger directional response. +- Depth no longer uses light-source response unless the night lighting system is actually active. +- Strong compression direction for weak servers: store indexed palette pixels as bit-packed palette indices plus an alpha bitmask and RLE/LZ on top; avoid PNG/base64 as the canonical server payload. + +## v13 compression and remix lineage changes + +- Palette index encoding is the canonical pixel format. Each pixel stores `.` for transparent or a one-character palette code. +- Compact export now supports `bp6`, a 6-bit packed plane format. Transparent plus the 62 palette codes fit in one 6-bit value. +- Compact export chooses the smallest representation per pixel plane: cropped raw, cropped RLE, or cropped 6-bit packing. +- Remix delta storage was removed again; remixes are stored as independent packed pixel planes. +- Deleting a source asset now cascades through remix descendants. Their island placements, dynamic summons, votes, hidden flags, and moderation rows are removed together. +- Sync events now include `asset.delete`; applying that event also cascades to remix descendants on the receiver. + + +## v14 rollback / interaction polish +- Removed remix delta saving and remix-source cascade deletion. +- Particles are cell-based again, like Light and Depth: choose color + direction, then paint emitter cells. +- Selected dynamic sprites bounce too, with a slight deterministic tilt. +- The object speech bubble is kept above the object and away from the DRAW dock. +- Added a compact clock in the upper-right corner. +- Reworked daylight/moonlight shadow transitions so evening fades sun shadows out before night, moon shadows fade in during night, and pre-dawn moon shadows disappear before sunrise. + +## v14b startup fix +- Restored `updateSelectedLabel()` so bootstrap no longer throws `ReferenceError`. + + +## v15 interaction polish +- Restored object speech bubbles above the actual rendered sprite, using sprite draw coordinates instead of tile-center estimates. +- Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers. +- Kept selected-object shadows mirrored from the ground line while the sprite jumps. +- Smoothed sky and overlay interpolation across dawn, day, evening, and night. diff --git a/app.js b/app.js index e42deda..82ebed8 100644 --- a/app.js +++ b/app.js @@ -3,8 +3,8 @@ console.info('Pixel Island Summoner loaded'); - const STORAGE_KEY = 'pixel-island-summoner:phase5d'; - const SAVE_SCHEMA = 10; + const STORAGE_KEY = 'pixel-island-summoner:phase5e'; + const SAVE_SCHEMA = 13; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -89,18 +89,23 @@ const els = { canvas: $('worldCanvas'), openEditor: $('openEditor'), + openCreate: $('openCreate'), + openCollection: $('openCollection'), + openMenu: $('openMenu'), closeEditor: $('closeEditor'), - placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), createAccount: $('createAccount'), accountNote: $('accountNote'), + placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), createAccount: $('createAccount'), accountNote: $('accountNote'), accountId: $('accountId'), accountPass: $('accountPass'), drawer: $('studioDrawer'), tabs: [...document.querySelectorAll('.tab')], panels: [...document.querySelectorAll('.tabPanel')], phaseLabel: $('phaseLabel'), phaseBar: $('phaseBar'), + analogClock: $('analogClock'), + analogClockHand: $('analogClockHand'), authorName: $('authorName'), selectedAssetName: $('selectedAssetName'), tileInfo: $('tileInfo'), toast: $('toast'), - selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleEdit: $('bubbleEdit'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), + selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleTeleport: $('bubbleTeleport'), bubbleMenu: $('bubbleMenu'), bubbleMenuActions: $('bubbleMenuActions'), bubbleEdit: $('bubbleEdit'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'), modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), @@ -116,7 +121,7 @@ sideSwitcher: $('sideSwitcher'), editRight: $('editRight'), editLeft: $('editLeft'), paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), - toolLight: $('toolLight'), toolParticle: $('toolParticle'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), + toolLight: $('toolLight'), toolParticle: $('toolParticle'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), advancedToolGroup: $('advancedToolGroup'), particleDirectionWrap: $('particleDirectionWrap'), particleDirection: $('particleDirection'), particleUseSelection: $('particleUseSelection'), particleClearRange: $('particleClearRange'), particleRangeStatus: $('particleRangeStatus'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'), @@ -167,6 +172,7 @@ let dynamicKind = 'human'; let lightPixels = []; let particlePixels = []; + let particleConfig = { enabled: false, c: 'f', dir: 'up' }; let doorPixel = { x: 8, y: 15 }; let editParentId = null; let editOriginalId = null; @@ -229,7 +235,9 @@ render(); }); - els.openEditor.addEventListener('click', () => setDrawerOpen(true)); + els.openEditor?.addEventListener('click', () => { clearWorldSelection(false); setDrawerOpen(true); }); + els.openCreate?.addEventListener('click', () => { setTab('draw'); clearWorldSelection(false); setDrawerOpen(true); }); + els.openCollection?.addEventListener('click', () => { setTab('library'); clearWorldSelection(false); setDrawerOpen(true); }); els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); els.tabs.forEach((button) => { @@ -237,19 +245,28 @@ }); els.authorName?.addEventListener('input', () => { - state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; + const fallback = state.account?.id || 'Local Artist'; + state.authorName = (els.authorName.value || fallback).trim() || fallback; if (state.account) state.account.name = state.authorName; saveState(); updateAccountUI(); renderLibrary(); }); - els.createAccount?.addEventListener('click', () => createLocalAccount()); + els.accountPass?.addEventListener('input', () => { + if (!state.account) return; + state.account.password = (els.accountPass.value || '').trim(); + saveState(); + updateAccountUI(); + }); + + els.createAccount?.addEventListener('click', () => createLocalAccount(false)); els.confirmPreviewPlace?.addEventListener('click', confirmPreviewPlacement); els.backToCanvas?.addEventListener('click', cancelPlacementPreview); els.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; + spriteCache.clear(); saveState(); render(); }); @@ -288,7 +305,12 @@ els.canvas.addEventListener('pointerup', onWorldPointerUp); els.canvas.addEventListener('pointercancel', onWorldPointerUp); els.canvas.addEventListener('wheel', onWorldWheel, { passive: false }); - els.canvas.addEventListener('contextmenu', (event) => event.preventDefault()); + els.canvas.addEventListener('contextmenu', (event) => { + event.preventDefault(); + cancelPlacementPreview(false); + clearWorldSelection(true); + setMode('inspect'); + }); document.addEventListener('contextmenu', onDocumentContextMenu); document.addEventListener('visibilitychange', onVisibilityChange); @@ -337,29 +359,43 @@ els.toolLight.addEventListener('click', () => setPaintTool('light')); els.toolDoor.addEventListener('click', () => setPaintTool('door')); els.toolDepth?.addEventListener('click', () => setPaintTool('depth')); + els.toolParticle?.addEventListener('click', () => { setPaintTool('particle'); enableParticleEffect(); }); + els.particleDirection?.addEventListener('change', () => { + const before = JSON.stringify(particleConfig); + particleConfig = normalizeParticleConfig({ ...particleConfig, dir: els.particleDirection.value || 'up' }); + if (particleConfig.enabled && JSON.stringify(particleConfig) !== before) markEditorChanged(); + updateParticleUI(); + drawEditor(); + }); + els.particleUseSelection?.addEventListener('click', setParticleRangeFromSelection); + els.particleClearRange?.addEventListener('click', clearParticleRange); els.depthHigh?.addEventListener('click', () => setDepthPaintMode(1)); els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1)); - els.depthClear?.addEventListener('click', () => setDepthPaintMode(0)); els.toggleAdvanced?.addEventListener('click', () => { advancedDraw = !advancedDraw; + toggleHidden(els.advancedToolGroup, !advancedDraw); toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); - toggleHidden(els.depthClear, !advancedDraw); + toggleHidden(els.particleDirectionWrap, !advancedDraw); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced.classList.toggle('active', advancedDraw); + els.drawer?.classList.toggle('advancedTools', advancedDraw); if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush'); + updateParticleUI(); drawEditor(); }); els.clearPaint.addEventListener('click', () => { + if (!confirm('Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.')) return; commitEditorMutation(() => { editorPixels = blankPixels(editorSize); editorLeftPixels = mirrorPixels(editorPixels, editorSize); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; particlePixels = []; + particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; editorSelection = null; return true; }); @@ -367,7 +403,7 @@ els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); els.flipVertical?.addEventListener('click', flipEditorVertical); els.outlinePaint?.addEventListener('click', applyEditorOutline); - els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); }); + els.clearSelection?.addEventListener('click', () => clearEditorSelection(true)); els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0)); els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0)); els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1)); @@ -381,7 +417,9 @@ els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); - els.bubbleReport?.addEventListener('click', () => openReportDialog()); + els.bubbleTeleport?.addEventListener('click', () => teleportToRemixSource()); + els.bubbleMenu?.addEventListener('click', () => toggleBubbleMenu()); + els.bubbleReport?.addEventListener('click', () => openReportDialog()); els.reportCancel?.addEventListener('click', () => closeReportDialog()); els.reportSubmit?.addEventListener('click', () => submitReportDialog()); els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); }); @@ -402,7 +440,7 @@ els.saveAsset.addEventListener('click', saveAssetFromEditor); els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); els.checkOnIsland?.addEventListener('click', checkCurrentEditorOnIsland); - els.newAsset.addEventListener('click', newAsset); + els.newAsset?.addEventListener('click', newAsset); els.exportData.addEventListener('click', exportData); els.importData.addEventListener('click', importData); els.exportCompact?.addEventListener('click', exportCompactData); @@ -448,7 +486,7 @@ function onDocumentContextMenu(event) { if (!els.drawer?.classList.contains('open')) return; - if (els.drawer.contains(event.target) || els.openEditor.contains(event.target)) return; + if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return; event.preventDefault(); setDrawerOpen(false); } @@ -460,6 +498,7 @@ function setMode(nextMode) { mode = nextMode; + if (mode === 'place' || mode === 'erase') clearWorldSelection(false); const isInspect = mode === 'inspect'; const isPlace = mode === 'place'; const isErase = mode === 'erase'; @@ -471,6 +510,7 @@ } function refreshCategoryUI() { + els.drawer?.classList.toggle('advancedTools', advancedDraw); const role = currentRole(); const isStatic = roleToCategory(role) === 'static'; if (isStatic) staticKind = roleToSubtype(role); @@ -481,9 +521,11 @@ toggleHidden(els.sideSwitcher, isStatic); toggleHidden(els.staticSettingsPanel, !isStatic); toggleHidden(els.dynamicSettingsPanel, true); + toggleHidden(els.advancedToolGroup, !advancedDraw); toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); + toggleHidden(els.particleDirectionWrap, !advancedDraw); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); @@ -496,6 +538,7 @@ updateSideButtons(); clampEditorView(); drawEditor(); + updateParticleUI(); updateSettingsSummary(); } @@ -503,8 +546,8 @@ if (!els.roleHint) return; const role = currentRole(); const textMap = { - human: 'Humans need ▶ Right and ◀ Left sprites. They hop often and visit buildings.', - animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.', + human: 'Humans move by target direction. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.', + animal: 'Animals move by target direction. Draw as ▶ Right, or press ◀ Left if your canvas is left-facing so Save mirrors it.', nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.', building: 'Buildings attract humans. Use Door to mark the entrance.', ship: 'Ships are static water objects. They float and emit ring ripples.', @@ -519,14 +562,13 @@ if (roleToCategory(role) === 'static') { const lightCount = lightPixels.length; const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light'; - const particleCount = particlePixels.length; - const particleText = particleCount ? `${particleCount} particle cell${particleCount === 1 ? '' : 's'}` : 'no particles'; + const particleText = particlePixels.length ? `particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : 'no particles'; const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText} / ${particleText}${doorText}.`; } else { - const particleCount = particlePixels.length; - const particleText = particleCount ? ` / ${particleCount} particle cell${particleCount === 1 ? '' : 's'}` : ''; - els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is auto-mirrored${particleText}.`; + const particleText = particlePixels.length ? ` / particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : ''; + const sideText = editingSide === 'left' ? 'canvas marked ◀ Left; Save mirrors it into canonical ▶ Right' : 'canvas marked canonical ▶ Right'; + els.settingsSummary.textContent = `${cap(role)} / ${sideText}${particleText}.`; } } @@ -536,35 +578,41 @@ ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, particle: els.toolParticle, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); const hints = { brush: 'Draw pixels with the selected palette color.', - erase: 'Erase pixels. Erasing also clears light markers on that cell.', + erase: 'Erase metadata first when Light/Depth/Particle exists on the cell; erase the pixel on the next click.', fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.', pick: 'Pick a color from the canvas and return to Draw.', line: 'Drag to draw a straight line. Right-click erases along the line.', rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', - select: 'Drag a rectangle to select pixels, then drag or nudge the selection.', + select: 'Left-click/drag to select pixels. Right-click clears the selection.', light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', - particle: 'Paint particle emitter cells with the selected palette color. Hold Shift or right-click to erase emitters.', + particle: 'Particle emits from the sprite or selected range. Palette chooses color; direction is set below. Shift-click disables particles; right-click pans.', door: 'Click one pixel to mark a building door. Humans will enter near this point.', - depth: 'Advanced: paint high or low depth. Use High/Low/Clear buttons; Shift clears.' + depth: 'Advanced: paint high or low depth. Use High/Low buttons; Shift/right-click clears.' }; els.editHint.textContent = hints[tool] || hints.brush; - [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); - if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); + [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); + if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); + updateParticleUI(); } function setDepthPaintMode(mode) { - depthPaintMode = mode === -1 ? -1 : mode === 0 ? 0 : 1; - [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); - ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); + depthPaintMode = mode === -1 ? -1 : 1; + [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); + ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); if (paintTool !== 'depth') setPaintTool('depth'); - const label = depthPaintMode > 0 ? 'high' : depthPaintMode < 0 ? 'low' : 'clear'; - if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Left-drag paints this depth value.`; + const label = depthPaintMode > 0 ? 'high' : 'low'; + if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Shift/right-click clears depth.`; } function setEditingSide(side) { if (roleToCategory(currentRole()) !== 'dynamic') return; - editingSide = side; + editingSide = side === 'left' ? 'left' : 'right'; updateSideButtons(); + if (els.editHint) { + els.editHint.textContent = editingSide === 'left' + ? 'Canvas is marked as left-facing. It will be mirrored into the canonical right-facing sprite on save.' + : 'Canvas is marked as the canonical right-facing sprite. Movement uses this for rightward travel.'; + } drawEditor(); } @@ -577,7 +625,9 @@ function onWorldPointerDown(event) { event.preventDefault(); if (event.button === 2) { - clearWorldSelection(false); + cancelPlacementPreview(false); + clearWorldSelection(true); + setMode('inspect'); pointer.down = false; pointer.id = null; return; @@ -870,6 +920,7 @@ } function canPublishObject(action = 'publish') { + if (!state.account?.createdAt) ensureLocalAccount(action === 'publish' ? 'publish' : 'republish'); const quota = getPublishQuotaStatus(); if (quota.accountRequired) { toast('Create a local account before publishing works to the island.'); @@ -894,12 +945,18 @@ } + + function shouldHideUnderPlacementPreview(assetId, x, y) { + return false; + } + function getDrawableItems(time = performance.now(), viewport = null) { const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); const items = []; for (const placed of state.placed) { const asset = findAsset(placed.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id] || isPermanentlyHiddenObject(placed) || !isWorldObjectVisibleByRotation('static', placed.id)) continue; + if (shouldHideUnderPlacementPreview(asset.id, placed.x + .5, placed.y + .5)) continue; const itemX = placed.x + .5; const itemY = placed.y + .5; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; @@ -908,6 +965,7 @@ for (const runtime of dynamicRuntime) { const asset = findAsset(runtime.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id] || isPermanentlyHiddenObject(runtime) || !isWorldObjectVisibleByRotation('dynamic', runtime.id)) continue; + if (shouldHideUnderPlacementPreview(asset.id, runtime.x, runtime.y)) continue; if (time < runtime.hiddenUntil) continue; if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); @@ -920,7 +978,17 @@ return items; } + function getObjectSortBottom(item) { + const pos = tileToWorld(item.x, item.y); + const asset = item.asset || {}; + if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(item.x, item.y); + const anchorY = asset.category === 'static' ? 0 : TILE_H / 2; + return pos.y + anchorY; + } + function drawOrderCompare(a, b) { + const bottomDelta = getObjectSortBottom(a) - getObjectSortBottom(b); + if (Math.abs(bottomDelta) > 0.01) return bottomDelta; return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || '')); } @@ -977,6 +1045,9 @@ localY = worldY - info.drawY; } if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false; + // Tiny 8×8 works are hard to click. Use the whole sprite box for them, + // including transparent cells, while larger works still use opaque pixels. + if ((asset.size || 0) <= 8) return true; const px = Math.floor(localX / scale); const py = Math.floor(localY / scale); if (px < 0 || py < 0 || px >= asset.size || py >= asset.size) return false; @@ -997,8 +1068,21 @@ } function clearWorldSelection(announce = false) { + const hadSelection = !!selectedObject; selectedObject = null; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; updateSelectionBubble(performance.now()); + if (announce && hadSelection) toast('Selection cleared.'); + } + + function updateSelectedLabel() { + if (selectedAssetId && !findAsset(selectedAssetId)) { + selectedAssetId = state.assets.find((asset) => !state.hiddenAssets?.[asset.id])?.id || state.assets[0]?.id || null; + } + const asset = findAsset(selectedAssetId); + if (els.selectedAssetName) { + els.selectedAssetName.textContent = asset ? `${asset.name || 'Untitled'} · ${cap(subtypeToRole(asset))}` : 'No collection work selected'; + } } function inspectAt(x, y) { @@ -1019,20 +1103,45 @@ function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) { selectedObject = { kind, id: objectId, assetId, selectedAt }; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; selectedAssetId = assetId; updateSelectedLabel(); renderLibrary(); updateSelectionBubble(selectedAt); } + + function isTileCompatibleForAsset(asset, tile) { + if (!tile) return false; + if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water'; + if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water'; + if (tile.type === 'water') return asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship'); + return true; + } + + function getRemixSourcePositions(asset) { + const ids = new Set([asset?.parentAssetId, asset?.originalAssetId].filter(Boolean)); + if (!ids.size) return []; + const out = []; + for (const placed of state.placed || []) if (ids.has(placed.assetId)) out.push({ x: placed.x, y: placed.y }); + for (const summon of state.dynamicSummons || []) if (ids.has(summon.assetId)) out.push({ x: summon.homeX, y: summon.homeY }); + return out; + } + + function avoidRemixSourceOverlap(asset, x, y) { + return { x, y, moved: false }; + } + function placeSelected(x, y) { if (placementPreview?.asset) { + const adjusted = avoidRemixSourceOverlap(placementPreview.asset, x, y); + x = adjusted.x; y = adjusted.y; const tile = world.get(x, y); if (!canPlace(placementPreview.asset, tile)) return; placementPreview.x = x; placementPreview.y = y; render(); - toast('Preview set. Use Place here or Back to canvas.'); + toast(adjusted.moved ? 'Preview nudged away from the remix source. Use Place here or Back to canvas.' : 'Preview set. Use Place here or Back to canvas.'); return; } const asset = findAsset(selectedAssetId); @@ -1042,8 +1151,11 @@ setTab('library'); return; } - const tile = world.get(x, y); + const adjusted = avoidRemixSourceOverlap(asset, x, y); + x = adjusted.x; y = adjusted.y; + let tile = world.get(x, y); if (!canPlace(asset, tile)) return; + if (adjusted.moved) toast('Placed slightly away from the remix source to avoid overlap.'); const existingObject = asset.category === 'static' ? state.placed.find((p) => p.assetId === asset.id) @@ -1198,11 +1310,15 @@ } function usesRightButtonAsPaint() { - return paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; + return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; } function onPaintPointerDown(event) { event.preventDefault(); + if (paintTool === 'select' && event.button === 2) { + clearEditorSelection(true); + return; + } els.paintCanvas.setPointerCapture?.(event.pointerId); lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); @@ -1289,6 +1405,10 @@ // Fallback for browsers/extensions where pointer events are swallowed. if (event.button !== 0 && event.button !== 1 && event.button !== 2) return; event.preventDefault(); + if (paintTool === 'select' && event.button === 2) { + clearEditorSelection(true); + return; + } lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button }; @@ -1357,9 +1477,7 @@ ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor) : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); if (nextDisplay.changed) { - editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(nextDisplay.pixels, editorSize) - : nextDisplay.pixels; + editorPixels = nextDisplay.pixels; editorLeftPixels = mirrorPixels(editorPixels, editorSize); if (fillColor === null && Array.isArray(nextDisplay.cells)) { for (const cell of nextDisplay.cells) { @@ -1376,14 +1494,18 @@ changed = true; } } else if (paintTool === 'erase') { - if (editorPixels[index] !== null) { + const hadLight = lightPixels.some((p) => p.x === point.x && p.y === point.y); + const hadParticle = particlePixels.some((p) => p.x === point.x && p.y === point.y); + const hadDepth = depthPixels[index] !== 0; + if (hadLight || hadParticle || hadDepth) { + removeLightPixel(point.x, point.y); + removeParticlePixel(point.x, point.y); + depthPixels[index] = 0; + changed = true; + } else if (editorPixels[index] !== null) { editorPixels[index] = null; changed = true; } - const before = lightPixels.length + particlePixels.length; - removeLightPixel(point.x, point.y); - removeParticlePixel(point.x, point.y); - changed = changed || before !== lightPixels.length + particlePixels.length; } else if (paintTool === 'light') { const before = JSON.stringify(lightPixels); if (shiftKey || button === 2) removeLightPixel(point.x, point.y); @@ -1393,11 +1515,13 @@ } else if (paintTool === 'particle') { const before = JSON.stringify(particlePixels); if (shiftKey || button === 2) removeParticlePixel(point.x, point.y); - else addParticlePixel(point.x, point.y, selectedColorCode); + else addParticlePixel(point.x, point.y, selectedColorCode, els.particleDirection?.value || particleConfig.dir || 'up'); + particleConfig = normalizeParticleConfig({ enabled: particlePixels.length > 0, c: selectedColorCode, dir: els.particleDirection?.value || particleConfig.dir || 'up' }); changed = JSON.stringify(particlePixels) !== before; + updateParticleUI(); updateSettingsSummary(); } else if (paintTool === 'depth') { - const next = shiftKey ? 0 : (button === 2 ? -1 : depthPaintMode); + const next = (shiftKey || button === 2) ? 0 : depthPaintMode; if (depthPixels[index] !== next) { depthPixels[index] = next; changed = true; @@ -1448,6 +1572,7 @@ depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), particlePixels: particlePixels.map((p) => ({ ...p })), + particleConfig: { ...particleConfig }, doorPixel: { ...doorPixel }, editingSide, selectedColorCode, @@ -1462,6 +1587,7 @@ depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; particlePixels = Array.isArray(snapshot.particlePixels) ? snapshot.particlePixels.map((p) => ({ ...p })) : []; + particleConfig = normalizeParticleConfig(snapshot.particleConfig || (particlePixels.length ? { enabled: true, c: particlePixels[0].c, dir: particlePixels[0].dir || 'up' } : particleConfig)); doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; @@ -1557,9 +1683,7 @@ const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key]; moveSelectionBy(delta[0], delta[1]); } else if (key === 'escape') { - editorSelection = null; - drawEditor(); - updateEditorSelectionButtons(); + clearEditorSelection(false); } else if (!event.ctrlKey && !event.metaKey && !event.altKey) { const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' }; if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); } @@ -1718,23 +1842,16 @@ } function getDisplayDepthPixels() { - const source = normalizeDepthPixels(depthPixels, editorSize); - return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorScalarPixels(source, editorSize) - : source; + return normalizeDepthPixels(depthPixels, editorSize); } function setDisplayEditorPixels(pixels) { - editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(pixels, editorSize) - : normalizePixels(pixels, editorSize); + editorPixels = normalizePixels(pixels, editorSize); editorLeftPixels = mirrorPixels(editorPixels, editorSize); } function setDisplayDepthPixels(pixels) { - depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorScalarPixels(pixels, editorSize) - : normalizeDepthPixels(pixels, editorSize); + depthPixels = normalizeDepthPixels(pixels, editorSize); } function mirrorScalarPixels(values, size) { @@ -1902,6 +2019,85 @@ if (els.clearSelection) els.clearSelection.disabled = disabled; } + function clearEditorSelection(announce = false) { + const hadSelection = !!editorSelection || !!selectionGesture; + editorSelection = null; + selectionGesture = null; + drawEditor(); + updateEditorSelectionButtons(); + if (announce && hadSelection) toast('Selection cleared.'); + } + + function normalizeParticleConfig(config, size = editorSize) { + const input = config && typeof config === 'object' ? config : {}; + const dir = ['up', 'down', 'left', 'right'].includes(input.dir) ? input.dir : 'up'; + const color = PALETTE_BY_CODE[input.c] ? input.c : nearestPaletteCode('#ffffff'); + return { enabled: Boolean(input.enabled), c: color, dir }; + } + + function particleDirectionLabel(dir) { + return ({ up: '↑', down: '↓', left: '←', right: '→' }[dir] || '↑'); + } + + function particleRangeLabel() { + return `${particlePixels.length} emitter cell${particlePixels.length === 1 ? '' : 's'}`; + } + + function updateParticleUI() { + particleConfig = normalizeParticleConfig(particleConfig); + if (els.particleDirection) els.particleDirection.value = particleConfig.dir; + if (els.toolParticle) els.toolParticle.classList.toggle('active', paintTool === 'particle' || particlePixels.length > 0); + if (els.particleRangeStatus) els.particleRangeStatus.textContent = `Particle cells: ${particlePixels.length}. Shift/right-click clears a cell.`; + if (els.particleClearRange) els.particleClearRange.disabled = particlePixels.length === 0; + } + + function enableParticleEffect() { + particleConfig = normalizeParticleConfig({ enabled: true, c: selectedColorCode, dir: els.particleDirection?.value || particleConfig.dir || 'up' }); + updateParticleUI(); + updateSettingsSummary(); + drawEditor(); + } + + function disableParticleEffect() { + if (!particlePixels.length && !particleConfig.enabled) return; + particleConfig = normalizeParticleConfig({ ...particleConfig, enabled: false }); + updateParticleUI(); + updateSettingsSummary(); + drawEditor(); + toast('Particle tool ready. Shift/right-click a particle cell to clear it.'); + } + + function displayRectToCanonicalRect(rect) { + return null; + } + + function setParticleRangeFromSelection() { + toast('Particle range is no longer used. Paint particle cells directly.'); + } + + function clearParticleRange() { + if (!particlePixels.length) return; + commitEditorMutation(() => { + particlePixels = []; + particleConfig = normalizeParticleConfig({ ...particleConfig, enabled: false }); + return true; + }); + toast('Particle cells cleared.'); + } + + function getPixelBounds(pixels, size) { + const source = normalizePixels(pixels, size); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (!source[y * size + x]) continue; + minX = Math.min(minX, x); minY = Math.min(minY, y); + maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); + } + } + return Number.isFinite(minX) ? { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 } : null; + } + function flipEditorHorizontal() { commitEditorMutation(() => { editorPixels = mirrorPixels(editorPixels, editorSize); @@ -2066,7 +2262,10 @@ if (!PALETTE_BY_CODE[code]) return; selectedColorCode = code; els.paintColor.value = PALETTE_BY_CODE[code]; + if (paintTool === 'particle') particleConfig = normalizeParticleConfig({ ...particleConfig, c: code }); renderPalette(); + updateParticleUI(); + updateSettingsSummary(); } function getActiveEditorPixels() { @@ -2074,18 +2273,14 @@ } function getDisplayEditorPixels() { - return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(editorPixels, editorSize) - : normalizePixels(editorPixels, editorSize); + return normalizePixels(editorPixels, editorSize); } function displayCellToCanonical(x, y) { - if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; return { x, y }; } function canonicalCellToDisplay(x, y) { - if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; return { x, y }; } @@ -2101,6 +2296,7 @@ els.assetSize.value = String(size); lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size); + particleConfig = normalizeParticleConfig(particleConfig); doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; resetEditorView(); drawEditor(); @@ -2178,7 +2374,7 @@ const lx = (shown.x + .5) * cell; const ly = (shown.y + .5) * cell; const color = colorToHex(emitter.c || selectedColorCode); - pctx.fillStyle = hexToRgba(color, .32); + pctx.fillStyle = hexToRgba(color, .20); pctx.fillRect(lx - Math.max(3, cell * .18), ly - Math.max(3, cell * .18), Math.max(6, cell * .36), Math.max(6, cell * .36)); pctx.strokeStyle = color; pctx.lineWidth = 2 / editorView.zoom; @@ -2220,10 +2416,10 @@ lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); } - function addParticlePixel(x, y, colorCode = selectedColorCode) { - const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode }; + function addParticlePixel(x, y, colorCode = selectedColorCode, dir = particleConfig.dir || 'up') { + const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode, dir: ['up','down','left','right'].includes(dir) ? dir : 'up' }; const existing = particlePixels.find((p) => p.x === point.x && p.y === point.y); - if (existing) existing.c = colorCode; + if (existing) { existing.c = colorCode; existing.dir = point.dir; } else particlePixels.push(point); } @@ -2232,18 +2428,50 @@ } + function mirrorLightPointsHorizontal(points, size) { + return (Array.isArray(points) ? points : []) + .map((p) => ({ ...p, x: size - 1 - clampInt(p.x, 0, size - 1, 0) })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size); + } + + function mirrorParticlePointsHorizontal(points, size) { + return (Array.isArray(points) ? points : []) + .map((p) => ({ ...p, x: size - 1 - clampInt(p.x, 0, size - 1, 0), dir: p.dir === 'left' ? 'right' : p.dir === 'right' ? 'left' : (p.dir || 'up') })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size); + } + + function shiftParticlePointsVertical(points, size, shift = 0) { + return (Array.isArray(points) ? points : []) + .map((p) => ({ ...p, y: clampInt(p.y, 0, size - 1, 0) + shift })) + .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size); + } + + + function normalizeEditorOrientationForSave(size) { + const dynamicLeftCanvas = roleToCategory(currentRole()) === 'dynamic' && editingSide === 'left'; + const rightPixels = dynamicLeftCanvas ? mirrorPixels(editorPixels, size) : normalizePixels(editorPixels, size); + const normalizedDepth = normalizeDepthPixels(depthPixels, size); + const orientedDepth = dynamicLeftCanvas ? mirrorDepthPixels(normalizedDepth, size) : normalizedDepth; + const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal(lightPixels, size) : lightPixels.map((p) => ({ ...p })); + const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal(particlePixels, size) : particlePixels.map((p) => ({ ...p })); + const orientedParticleConfig = normalizeParticleConfig({ ...particleConfig, enabled: orientedParticles.length > 0 }, size); + return { rightPixels, depthPixels: orientedDepth, lightPixels: orientedLights, particlePixels: orientedParticles, particleConfig: orientedParticleConfig, mirroredFromLeft: dynamicLeftCanvas }; + } + + function alignEditorStateToBottom(size) { - const shift = getBottomShift(editorPixels, size); + const oriented = normalizeEditorOrientationForSave(size); + const shift = getBottomShift(oriented.rightPixels, size); return { - rightPixels: shiftPixelsVertical(editorPixels, size, shift), - depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift), - lightPixels: lightPixels + rightPixels: shiftPixelsVertical(oriented.rightPixels, size, shift), + depthPixels: shiftDepthPixelsVertical(oriented.depthPixels, size, shift), + lightPixels: oriented.lightPixels .map((p) => ({ ...p, y: p.y + shift })) .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), - particlePixels: particlePixels - .map((p) => ({ ...p, y: p.y + shift })) - .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), - door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) } + particlePixels: shiftParticlePointsVertical(oriented.particlePixels, size, shift), + particleConfig: normalizeParticleConfig({ ...oriented.particleConfig, enabled: oriented.particlePixels.length > 0 }, size), + door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) }, + mirroredFromLeft: oriented.mirroredFromLeft }; } @@ -2325,7 +2553,7 @@ toast(`${asset.name} updated.`); } else { state.assets.unshift(asset); - toast(`${asset.name} saved.`); + toast(aligned.mirroredFromLeft ? `${asset.name} saved. Left-facing canvas was mirrored into right-facing movement.` : `${asset.name} saved.`); } selectedAssetId = asset.id; recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); @@ -2337,17 +2565,22 @@ hydrateRuntime(); renderLibrary(); updateSelectedLabel(); - els.lineageNote.textContent = 'Saved as a permanent library work. Island placement is a separate temporary exhibition object.'; + els.lineageNote.textContent = 'Saved as a permanent collection work. Island placement is a separate temporary exhibition object.'; return asset; } function saveAndPlaceFromEditor() { + ensureLocalAccount('save-place'); const asset = saveAssetFromEditor(); if (!asset) return; selectedAssetId = asset.id; + placementPreview = { asset, x: null, y: null, savedAssetId: asset.id }; + clearWorldSelection(false); setMode('place'); setDrawerOpen(false); - toast('Saved to Library. Click an island tile to place it as a temporary exhibition object.'); + if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; + if (els.openEditor) els.openEditor.hidden = true; + toast('Saved to Collection. Click a tile, then choose Place here or Back to canvas.'); } @@ -2380,14 +2613,17 @@ setDrawerOpen(false); setMode('place'); if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; + if (els.openEditor) els.openEditor.hidden = true; toast('Click a valid tile to preview. Then choose Place here or Back to canvas.'); } - function cancelPlacementPreview() { + function cancelPlacementPreview(reopenDrawer = true) { + const hadPreview = !!placementPreview; placementPreview = null; if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; - setDrawerOpen(true); - render(); + if (!reopenDrawer && els.openEditor) els.openEditor.hidden = false; + if (reopenDrawer) setDrawerOpen(true); + if (hadPreview) render(); } function confirmPreviewPlacement() { @@ -2395,12 +2631,16 @@ toast('Click a tile first.'); return; } - const asset = saveAssetFromEditor(); - if (!asset) return; - selectedAssetId = asset.id; + const assetId = placementPreview.savedAssetId || placementPreview.asset.id; + if (!findAsset(assetId)) { + toast('Saved asset is missing. Back to canvas and save again.'); + return; + } + selectedAssetId = assetId; const { x, y } = placementPreview; placementPreview = null; if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; + if (els.openEditor) els.openEditor.hidden = false; placeSelected(x, y); } @@ -2414,6 +2654,7 @@ editingSide = 'right'; lightPixels = []; particlePixels = []; + particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; setupEditor(8, blankPixels(8), null); @@ -2424,35 +2665,82 @@ function hydrateAuthorUI() { - state.authorName = state.authorName || 'Local Artist'; + state.account = normalizeAccount(state.account); + state.authorName = state.account?.name || state.authorName || 'Local Artist'; if (els.authorName) els.authorName.value = state.authorName; + updateAccountUI(); } - function createLocalAccount() { - const name = (els.authorName?.value || state.authorName || '').trim(); - if (!name || name === 'Local Artist') { - toast('Choose a name first.'); - return; + function normalizeAccount(account) { + if (!account?.createdAt) return null; + const id = String(account.id || `px-${uid()}`).replace(/^local:/, 'px-'); + const password = String(account.password || account.pass || generateLocalPassword()); + const name = String(account.name || id).trim() || id; + return { id, name, password, createdAt: Number(account.createdAt) || Date.now() }; + } + + function generateAccountId() { + return `px-${uid().replace(/[^a-z0-9]/gi, '').slice(0, 10)}`; + } + + function generateLocalPassword() { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; + if (globalThis.crypto?.getRandomValues) { + const bytes = new Uint8Array(12); + globalThis.crypto.getRandomValues(bytes); + return [...bytes].map((b) => alphabet[b % alphabet.length]).join(''); + } + return Array.from({ length: 12 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(''); + } + + function createLocalAccount(silent = false) { + if (!state.account?.createdAt) { + const id = generateAccountId(); + state.account = { id, name: id, password: generateLocalPassword(), createdAt: Date.now() }; + state.authorName = id; + } else { + const name = (els.authorName?.value || state.account.name || state.account.id).trim() || state.account.id; + state.account = normalizeAccount({ ...state.account, name, password: (els.accountPass?.value || state.account.password || '').trim() || generateLocalPassword() }); + state.authorName = state.account.name; } - state.authorName = name; - state.account = state.account || { id: `local:${uid()}`, createdAt: Date.now() }; - state.account.name = name; saveState(); updateAccountUI(); - toast('Local account created for prototype publishing.'); + renderLibrary(); + if (!silent) toast('Local account generated. Change the password after creation.'); + return state.account; + } + + function ensureLocalAccount(reason = 'publish') { + if (state.account?.createdAt) { + state.account = normalizeAccount(state.account); + state.authorName = state.account.name; + updateAccountUI(); + return state.account; + } + const account = createLocalAccount(true); + toast(reason === 'save-place' ? 'Local account auto-generated. You can rename it and should change the password.' : 'Local account auto-generated for publishing. Change the password after creation.'); + return account; } function updateAccountUI() { if (!els.accountNote) return; if (!state.account?.createdAt) { - els.accountNote.textContent = 'Account required to publish. This prototype only stores a local name and creation date. Works are permanent; island placements rotate.'; - els.createAccount.hidden = false; + if (els.accountId) els.accountId.value = ''; + if (els.authorName) els.authorName.value = state.authorName || 'Local Artist'; + if (els.accountPass) els.accountPass.value = ''; + els.accountNote.textContent = 'Save + Place creates a local ID, name, and password. Change the password after creation.'; + if (els.createAccount) els.createAccount.hidden = false; return; } + state.account = normalizeAccount(state.account); + state.authorName = state.account.name; + if (els.accountId) els.accountId.value = state.account.id; + if (els.authorName) els.authorName.value = state.account.name; + if (els.accountPass) els.accountPass.value = state.account.password || ''; const quota = getPublishQuotaStatus(); const day = getAccountAgeMs() < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+'; - els.accountNote.textContent = `Local account active (${day}). Publish quota: ${quota.used}/${quota.limit} this hour.`; - els.createAccount.hidden = true; + els.accountNote.textContent = `Account ${day}; publish ${quota.used}/${quota.limit} this hour. Change the password from the generated default.`; + if (els.createAccount) els.createAccount.hidden = true; } function focusAssetInWorld(asset) { @@ -2525,7 +2813,7 @@ const filters = ['all', 'human', 'animal', 'nature', 'building', 'ship', 'other']; const index = filters.indexOf(libraryFilter); libraryFilter = filters[(index + 1) % filters.length]; - toast(`Library filter: ${libraryFilterLabel()}`); + toast(`Collection filter: ${libraryFilterLabel()}`); } function makeAssetCard(asset) { @@ -2607,10 +2895,10 @@ } const liked = [...likedIds].map(findAsset).filter(Boolean).slice(0, 12); if (!liked.length) { - els.likedCodex.innerHTML = '
Liked codex
Works you upvote will appear here.
'; + els.likedCodex.innerHTML = '
Favorite
Works you upvote appear here.
'; return; } - els.likedCodex.innerHTML = '
Liked codex
'; + els.likedCodex.innerHTML = '
Favorite
'; const list = els.likedCodex.querySelector('.likedCodexList'); for (const asset of liked) { const item = document.createElement('button'); @@ -2648,7 +2936,7 @@ if (hiddenAssets.length) { const title = document.createElement('div'); title.className = 'hiddenSectionTitle'; - title.textContent = 'Hidden library assets'; + title.textContent = 'Hidden collection works'; els.hiddenAssetList.append(title); for (const asset of hiddenAssets) els.hiddenAssetList.append(makeHiddenAssetCard(asset)); } @@ -2674,9 +2962,9 @@ function hiddenReasonLabel(object) { if (!object) return 'Hidden locally'; - if (object.status === 'hidden_rotation') return 'Not shown on island: exhibition is full. Works remain in your Library.'; + if (object.status === 'hidden_rotation') return 'Not shown on island: exhibition is full. Works remain in your Collection.'; if (object.status === 'permanent_hidden' || object.status === 'violation_hidden') return 'Hidden by moderation. This work is not visible to authors or viewers.'; - if (object.hiddenReason === 'rotation_cap' || object.hiddenReason === 'extreme_downvotes') return 'Not shown on island: exhibition is full. Works remain in your Library.'; + if (object.hiddenReason === 'rotation_cap' || object.hiddenReason === 'extreme_downvotes') return 'Not shown on island: exhibition is full. Works remain in your Collection.'; return 'Hidden locally'; } @@ -2779,10 +3067,14 @@ swatch.dataset.code = entry.code; swatch.title = `${entry.code} ${entry.color}`; swatch.style.background = entry.color; - swatch.addEventListener('click', () => { + const choose = (event) => { + event.preventDefault(); + event.stopPropagation(); selectPaletteCode(entry.code); drawEditor(); - }); + }; + swatch.addEventListener('pointerdown', choose); + swatch.addEventListener('click', (event) => event.preventDefault()); els.paletteGrid.append(swatch); } els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b'; @@ -2803,34 +3095,29 @@ function loadAssetIntoEditor(asset, mode = 'remix') { setDrawerOpen(true); setTab('draw'); - const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); - const editExisting = mode === 'edit' && mine; - editingAssetId = editExisting ? asset.id : null; - editParentId = editExisting ? asset.parentAssetId : asset.id; - editOriginalId = editExisting ? asset.originalAssetId : (asset.originalAssetId || asset.id); - els.assetName.value = editExisting ? asset.name : `${asset.name} Remix`; + const editExisting = false; + editingAssetId = null; + editParentId = asset.id; + editOriginalId = asset.originalAssetId || asset.id; + els.assetName.value = `${asset.name} Remix`; els.assetCategory.value = subtypeToRole(asset); staticKind = asset.category === 'static' ? asset.subtype : staticKind; dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; editingSide = 'right'; const right = getAssetPixels(asset, 'right'); lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); - particlePixels = (asset.meta?.particlePixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || selectedColorCode })); + particlePixels = (asset.meta?.particlePixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || selectedColorCode, dir: p.dir || 'up' })); + particleConfig = normalizeParticleConfig((particlePixels.length ? { enabled: true, c: particlePixels[0].c || selectedColorCode, dir: particlePixels[0].dir || 'up' } : { enabled: false, c: selectedColorCode, dir: 'up' }), asset.size); depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size); doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 }; setupEditor(asset.size, right, null, depthPixels); clearEditorHistory(); refreshCategoryUI(); - els.lineageNote.textContent = editExisting - ? `Editing original “${asset.name}”. Save updates this asset.` - : `Remixing “${asset.name}”. Save creates a separate new asset.`; + els.lineageNote.textContent = `Remixing “${asset.name}”. Save creates a separate new collection work.`; } function editOriginalAsset(asset) { remixEdit(asset); - return; - - loadAssetIntoEditor(asset, 'edit'); } function remixEdit(asset) { @@ -2843,23 +3130,38 @@ function deleteAsset(asset) { + if (!asset) return; const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); - if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return; + const usageText = used ? ' Island placements that use it will also be removed.' : ''; + if (!confirm(`Delete “${asset.name}”?${usageText}`)) return; + + const removedObjectIds = new Set(); + for (const placed of state.placed || []) if (placed.assetId === asset.id) removedObjectIds.add(placed.id); + for (const summon of state.dynamicSummons || []) if (summon.assetId === asset.id) removedObjectIds.add(summon.id); + state.assets = state.assets.filter((a) => a.id !== asset.id); state.placed = state.placed.filter((p) => p.assetId !== asset.id); state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id); + + delete state.assetVotes?.[asset.id]; + delete state.hiddenAssets?.[asset.id]; + for (const id of removedObjectIds) { + delete state.objectVotes?.[id]; + delete state.hiddenObjects?.[id]; + } + state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); + if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null; + if (selectedObject && (selectedObject.assetId === asset.id || removedObjectIds.has(selectedObject.id))) selectedObject = null; + + recordSyncEvent(Phase2Sync?.createAssetDeleteEvent?.(asset.id)); saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); - } - - function updateSelectedLabel() { - if (!els.selectedAssetName) return; - const asset = findAsset(selectedAssetId); - els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none'; + updateSelectionBubble(performance.now()); + toast(`${asset.name} deleted.`); } function hydrateRuntime() { @@ -2879,6 +3181,7 @@ targetX: spawn.x + .5, targetY: spawn.y + .5, vx: 0, + lastMoveX: 0, facing: 1, idleUntil: performance.now() + 700 + Math.random() * 1600, hiddenUntil: 0, @@ -2927,12 +3230,14 @@ } if (time < (item.idleUntil || 0)) { item.vx = 0; + item.lastMoveX = 0; continue; } const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); if (time < (item.idleUntil || 0)) { item.vx = 0; + item.lastMoveX = 0; continue; } @@ -2941,10 +3246,13 @@ const len = Math.hypot(dx, dy) || 1; const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; const step = Math.min(speed, len); - if (Math.abs(dx) > 0.02) item.facing = Math.sign(dx); - item.vx = Math.abs(dx) > 0.02 ? Math.sign(dx) : 0; - item.x += (dx / len) * step; - item.y += (dy / len) * step; + const moveX = (dx / len) * step; + const moveY = (dy / len) * step; + if (Math.abs(moveX) > 0.003) item.facing = Math.sign(moveX); + item.vx = Math.abs(moveX) > 0.003 ? Math.sign(moveX) : 0; + item.lastMoveX = moveX; + item.x += moveX; + item.y += moveY; if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) { const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1)); @@ -2967,6 +3275,7 @@ item.targetX = item.x; item.targetY = item.y; item.vx = 0; + item.lastMoveX = 0; item.nextDecisionAt = item.idleUntil + 400 + Math.random() * 1200; return; } @@ -3055,46 +3364,107 @@ function updateClock() { const phase = getPhase(); + const progress = phase.progress || 0; + const degrees = progress * 360; + if (els.analogClockHand) els.analogClockHand.style.transform = `translate(-50%, -100%) rotate(${degrees.toFixed(2)}deg)`; + if (els.analogClock) { + els.analogClock.style.setProperty('--clock-rotate', `${degrees.toFixed(2)}deg`); + els.analogClock.setAttribute('aria-label', `${phase.label} island clock`); + els.analogClock.title = `${phase.label} · 10 min = 1 island day`; + } const second = Math.floor(Date.now() / 1000); - if (second === lastClockSecond) return; - lastClockSecond = second; - els.phaseLabel.textContent = phase.label; - els.phaseBar.style.width = `${Math.round(phase.progress * 100)}%`; + if (second !== lastClockSecond) { + lastClockSecond = second; + if (els.phaseLabel) els.phaseLabel.textContent = phase.label; + } + if (els.phaseBar) els.phaseBar.style.width = `${Math.round(progress * 100)}%`; } function getShadowForMinute(minute) { - const isNight = minute >= 6; - const local = isNight ? (minute - 6) / 4 : minute / 6; - const eased = clamp(local, 0, 1); - const elevation = Math.sin(eased * Math.PI); - // Morning: down-left, Noon: straight down, Evening: down-right. - // The top edge of the shadow is always anchored to the sprite's bottom edge. - const dirX = lerp(-1.0, 1.0, eased); - const length = lerp(1.35, 0.58, elevation); + const m = clamp(Number(minute) || 0, 0, 10); + const smooth = (t) => clamp(t, 0, 1) * clamp(t, 0, 1) * (3 - 2 * clamp(t, 0, 1)); + let dirX = -0.9; + let length = 1.2; + let alpha = 0; + let scaleY = 0.26; + + if (m < 0.65) { + // Pre-dawn: moon shadow fades out completely before morning begins. + const t = smooth(m / 0.65); + dirX = lerp(0.42, 0.18, t); + length = lerp(1.05, 0.92, t); + alpha = lerp(0.12, 0.0, t); + scaleY = lerp(0.22, 0.18, t); + } else if (m < 1.35) { + // Morning: sunlight shadow appears from zero. + const t = smooth((m - 0.65) / 0.70); + dirX = lerp(-1.05, -0.82, t); + length = lerp(1.38, 1.12, t); + alpha = lerp(0.0, 0.27, t); + scaleY = lerp(0.22, 0.27, t); + } else if (m < 5.65) { + // Day: rotate gradually with the sun, no hard switch in the evening. + const t = smooth((m - 1.35) / 4.30); + const elevation = Math.sin(t * Math.PI); + dirX = lerp(-0.82, 0.98, t); + length = lerp(1.12, 1.34, t) - elevation * 0.48; + alpha = lerp(0.27, 0.20, elevation); + scaleY = 0.24 + 0.06 * (1 - elevation); + } else if (m < 6.40) { + // Evening: keep the same sun-side direction and fade to zero before night. + const t = smooth((m - 5.65) / 0.75); + dirX = lerp(0.98, 1.08, t); + length = lerp(1.34, 1.56, t); + alpha = lerp(0.20, 0.0, t); + scaleY = lerp(0.28, 0.20, t); + } else if (m < 7.25) { + // Night starts after the sunlight shadow disappears; moon shadow fades in. + const t = smooth((m - 6.40) / 0.85); + dirX = lerp(0.20, -0.36, t); + length = lerp(0.92, 1.08, t); + alpha = lerp(0.0, 0.12, t); + scaleY = lerp(0.18, 0.23, t); + } else if (m < 9.20) { + const t = smooth((m - 7.25) / 1.95); + dirX = lerp(-0.36, -0.58, t); + length = lerp(1.08, 1.18, t); + alpha = 0.12; + scaleY = 0.23; + } else { + // Before dawn: moon shadow darkens/fades out to zero, then morning creates a new sun shadow. + const t = smooth((m - 9.20) / 0.80); + dirX = lerp(-0.58, 0.42, t); + length = lerp(1.18, 1.05, t); + alpha = lerp(0.12, 0.0, t); + scaleY = lerp(0.23, 0.18, t); + } + return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), - scaleY: 0.24 + 0.06 * length, - alpha: isNight ? .12 : lerp(.28, .16, elevation) + scaleY, + alpha }; } function getPhase() { if (visualSettings().enableDayNight === false) { - return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; + return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; } const t = mod(Date.now(), DAY_MS); const minute = t / 60000; const stops = [ - // Fast dawn: sky shifts quickly, then stays stable through the day. - { at: 0.00, key: 'morning', label: 'Morning', sky: '#f6b06f', darkness: 0.22, tint: [255, 204, 132, 0.16] }, - { at: 0.55, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 5.00, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - // Fast dusk: the main sky movement happens here. - { at: 5.55, key: 'evening', label: 'Evening', sky: '#f09076', darkness: 0.22, tint: [255, 128, 96, 0.15] }, - { at: 6.10, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] }, - { at: 10.00, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] } + { at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }, + { at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] }, + { at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] }, + { at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, + { at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, + { at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] }, + { at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] }, + { at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] }, + { at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] }, + { at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] } ]; let a = stops[0], b = stops[1]; for (let i = 0; i < stops.length - 1; i++) { @@ -3103,19 +3473,63 @@ const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); const eased = localT * localT * (3 - 2 * localT); const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); - const label = minute < 0.55 ? 'Morning' : minute < 5 ? 'Day' : minute < 6.10 ? 'Evening' : 'Night'; + const overlay = a.overlay.map((v, i) => lerp(v, b.overlay[i], eased)); + const darkness = lerp(a.darkness, b.darkness, eased); + const mixSky = mixHex(a.sky, b.sky, eased); + const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day')); + const label = dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day'; return { - key: label.toLowerCase(), + key: dominantKey, label, progress: t / DAY_MS, - sky: mixHex(a.sky, b.sky, eased), - darkness: lerp(a.darkness, b.darkness, eased), + sky: mixSky, + darkness, + darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`, tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, tintAlpha: tint[3], shadow: getShadowForMinute(minute) }; } + function areNightLightsActive(phase = renderPhase) { + return visualSettings().enableLights !== false && phase?.key === 'night'; + } + + function collectLightSourcesForItems(items, time, phase) { + if (!areNightLightsActive(phase) || !items?.length) return []; + const sources = []; + for (const item of items) { + const asset = item.asset; + if (!asset.meta?.hasLight || !asset.meta.lightPixels?.length) continue; + const info = getSpriteDrawInfo(item, time, true); + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const pivotX = info.drawX + info.sprite.width / 2; + const pivotY = info.drawY + info.sprite.height * 0.8; + for (const light of asset.meta.lightPixels) { + const lx = asset.category === 'dynamic' && info.side === 'left' ? asset.size - 1 - light.x : light.x; + let wx = info.drawX + (lx + 0.5) * scale; + let wy = info.drawY + (light.y + 0.5) * scale; + if (info.angle) { + const dx = wx - pivotX; + const dy = wy - pivotY; + const cos = Math.cos(info.angle); + const sin = Math.sin(info.angle); + wx = pivotX + dx * cos - dy * sin; + wy = pivotY + dx * sin + dy * cos; + } + sources.push({ + x: wx, + y: wy, + color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), + radius: lerp(13, 22, asset.size / 64), + ownerId: item.source?.id || asset.id, + flickerSeed: parseInt(fnv1a(`${item.source?.id || asset.id}:${light.x},${light.y}`).slice(0, 6), 16) || 1 + }); + } + } + return sources; + } + function render(time = performance.now()) { const phase = getPhase(); renderPhase = phase; @@ -3129,7 +3543,6 @@ ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; drawTerrainCache(terrainCache); - if (visualSettings().enableParticles) drawCoastalFoam(time); drawHoverTile(); const lightSources = []; const visibleItems = drawObjects(time, lightSources, phase); @@ -3141,7 +3554,7 @@ drawSpawnEffects(time); drawConfettiParticles(time); } - if (visualSettings().enableLights) drawSurfaceLightBleed(visibleItems, lightSources, time); + const nightLightsActive = areNightLightsActive(phase); ctx.restore(); updateSelectionBubble(time); @@ -3150,10 +3563,10 @@ ctx.fillRect(0, 0, cw, ch); } if (phase.darkness > 0) { - ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`; + ctx.fillStyle = phase.darkOverlay || `rgba(12, 19, 45, ${phase.darkness})`; ctx.fillRect(0, 0, cw, ch); } - if (visualSettings().enableLights && phase.darkness > 0) drawLightSources(lightSources, phase.darkness); + if (nightLightsActive && phase.darkness > 0) drawLightSources(lightSources, phase.darkness, time); } function drawHoverTile() { @@ -3197,15 +3610,17 @@ function drawObjects(time, lightSources, phase) { const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); + const activeLights = collectLightSourcesForItems(items, time, phase); + if (lightSources && activeLights.length) lightSources.push(...activeLights); for (const item of items) { if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { - drawSpriteItem(item, time, lightSources, true, phase); + drawSpriteItem(item, time, activeLights, true, phase); } } for (const item of items) { if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) { - drawSpriteItem(item, time, lightSources, false, phase); + drawSpriteItem(item, time, activeLights, false, phase); } } return items; @@ -3217,33 +3632,42 @@ const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird'); if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y); let bob = 0; - if (includeSelectBounce && asset.category === 'static' && selectedObject?.id === item.source?.id) { - const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 520, 0, 1); - if (selectedT < 1) bob += -Math.sin(selectedT * Math.PI) * 8; - } let alpha = 1; let side = 'right'; let angle = 0; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6; if (asset.category === 'dynamic') { - const runtime = item.source; - side = (runtime.facing || runtime.vx || 1) < 0 ? 'left' : 'right'; + const runtime = item.source || {}; + const seed = Number.isFinite(runtime.seed) ? runtime.seed : 0; + const isPreview = Boolean(item.preview || runtime.preview); + const moveFacing = Math.abs(runtime.lastMoveX || 0) > 0.003 ? Math.sign(runtime.lastMoveX) : (runtime.facing || runtime.vx || 1); + side = isPreview ? 'right' : (moveFacing < 0 ? 'left' : 'right'); if (asset.subtype === 'human' || asset.subtype === 'animal') { - const idle = time < (runtime.idleUntil || 0); + const idle = isPreview || time < (runtime.idleUntil || 0); if (!idle) { - const jumpPhase = time / 78 + runtime.seed * 0.9; + const jumpPhase = time / 78 + seed * 0.9; const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); const liftNow = getLiftAtCoord(item.x, item.y); const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); const terrainBoost = Math.abs(liftTarget - liftNow) / 12; bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); - const rand = pseudoNoise(cycle + runtime.seed * 13.17); + const rand = pseudoNoise(cycle + seed * 13.17); angle = (rand - 0.5) * 0.24 * hop; } } - if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5; - if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; } + if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + seed) * 4.5; + if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + seed) * 1.7; } + } + if (includeSelectBounce && selectedObject?.id === item.source?.id) { + const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 560, 0, 1); + if (selectedT < 1) { + const bounce = Math.sin(selectedT * Math.PI); + bob += -bounce * 8; + const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1; + const tiltSign = pseudoNoise(tiltSeed) > 0.5 ? 1 : -1; + angle += tiltSign * bounce * 0.18; + } } const sprite = getSpriteCanvas(asset, side); const drawX = pos.x - sprite.width / 2; @@ -3253,11 +3677,12 @@ } function drawSpriteItem(item, time, lightSources, underwater, phase) { - const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); + const info = getSpriteDrawInfo(item, time, true); + const { asset, pos, sprite, drawX, drawY, alpha, angle } = info; if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id); - if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); + if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(info, phase); ctx.save(); ctx.globalAlpha = alpha; @@ -3275,66 +3700,98 @@ } ctx.restore(); - if (asset.meta?.hasLight && asset.meta.lightPixels?.length) { - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - for (const light of asset.meta.lightPixels) { - const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x; - const wx = drawX + (lx + 0.5) * scale; - const wy = drawY + (light.y + 0.5) * scale; - lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(10, 16, asset.size / 64), ownerId: item.source?.id || asset.id }); - } - } + drawSpriteLightReflection(info, item, lightSources, phase); } - function drawSurfaceLightBleed(items, lightSources, time) { - if (!items?.length || !lightSources?.length) return; - for (const item of items) { - const info = getSpriteDrawInfo(item, time, false); - const sprite = info.sprite; - const localSources = []; - for (const source of lightSources) { - if (source.ownerId === item.source?.id) continue; - const dx = source.x - (info.drawX + sprite.width / 2); - const dy = source.y - (info.drawY + sprite.height / 2); - const reach = source.radius * 2.2 + Math.max(sprite.width, sprite.height) * 0.55; - const distance = Math.hypot(dx, dy); - if (distance > reach) continue; - localSources.push({ source, distance }); - } - if (!localSources.length) continue; - localSources.sort((a, b) => a.distance - b.distance); - const overlay = document.createElement('canvas'); - overlay.width = sprite.width; - overlay.height = sprite.height; - const octx = overlay.getContext('2d'); - octx.imageSmoothingEnabled = false; - octx.drawImage(sprite, 0, 0); - octx.globalCompositeOperation = 'source-atop'; - for (const { source, distance } of localSources.slice(0, 2)) { - const localX = source.x - info.drawX; - const localY = source.y - info.drawY; - const radius = Math.max(12, source.radius * 1.7); - const influence = 1 - clamp(distance / (source.radius * 2.4 + 36), 0, 1); - if (influence <= 0.02) continue; - const grad = octx.createRadialGradient(localX, localY, 0, localX, localY, radius); - grad.addColorStop(0, hexToRgba(source.color, 0.22 * influence)); - grad.addColorStop(0.5, hexToRgba(source.color, 0.10 * influence)); - grad.addColorStop(1, 'rgba(255,255,255,0)'); - octx.fillStyle = grad; - octx.fillRect(0, 0, overlay.width, overlay.height); - } - ctx.save(); - ctx.globalAlpha = 0.9; - if (info.angle) { - ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); - ctx.rotate(info.angle); - ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); - } else { - ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); - } - ctx.restore(); + function spriteLocalPoint(info, worldX, worldY) { + if (!info.angle) return { x: worldX - info.drawX, y: worldY - info.drawY }; + const pivotX = info.drawX + info.sprite.width / 2; + const pivotY = info.drawY + info.sprite.height * 0.8; + const dx = worldX - pivotX; + const dy = worldY - pivotY; + const cos = Math.cos(-info.angle); + const sin = Math.sin(-info.angle); + return { + x: dx * cos - dy * sin + info.sprite.width / 2, + y: dx * sin + dy * cos + info.sprite.height * 0.8 + }; + } + + function drawSpriteLightReflection(info, item, lightSources, phase) { + if (!areNightLightsActive(phase) || !lightSources?.length) return; + const asset = item.asset; + const ownerId = item.source?.id || asset.id; + const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; + const sprite = info.sprite; + const sources = []; + for (const source of lightSources) { + if (source.ownerId === ownerId) continue; + const local = spriteLocalPoint(info, source.x, source.y); + const dx = local.x - sprite.width / 2; + const dy = local.y - sprite.height / 2; + const reach = source.radius * 4.9 + Math.max(sprite.width, sprite.height) * 1.15; + const distance = Math.hypot(dx, dy); + if (distance <= reach) sources.push({ source, local, distance }); } + if (!sources.length) return; + sources.sort((a, b) => a.distance - b.distance); + + const pixels = getAssetPixels(asset, info.side || 'right'); + const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); + const depths = asset.category === 'dynamic' && info.side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths; + const overlay = document.createElement('canvas'); + overlay.width = sprite.width; + overlay.height = sprite.height; + const octx = overlay.getContext('2d'); + octx.imageSmoothingEnabled = false; + + for (let y = 0; y < asset.size; y++) { + for (let x = 0; x < asset.size; x++) { + if (!pixels[y * asset.size + x]) continue; + const depth = depths[y * asset.size + x] || 0; + let rr = 0, gg = 0, bb = 0, aa = 0; + for (const { source, local } of sources.slice(0, 3)) { + const lc = parseHex(source.color || '#ffd86a'); + if (!lc) continue; + const cellPx = (x + 0.5) * scale; + const cellPy = (y + 0.5) * scale; + const d = Math.hypot(local.x - cellPx, local.y - cellPy); + const reach = Math.max(scale * 2.2, source.radius * 3.8); + const t = 1 - clamp(d / reach, 0, 1); + if (t <= 0) continue; + const edge = getDepthLightFacing(depth, x, y, asset.size, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5); + const depthFactor = depth > 0 ? 1.05 + edge * 0.38 : depth < 0 ? 0.72 + edge * 0.18 : 0.50 + edge * 0.10; + const amount = t * t * depthFactor; + rr += lc.r * amount; + gg += lc.g * amount; + bb += lc.b * amount; + aa += amount; + } + if (aa <= 0.002) continue; + const inv = 1 / aa; + const alpha = Math.min(0.34, 0.08 + aa * 0.18); + octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${alpha.toFixed(3)})`; + octx.fillRect(x * scale, y * scale, scale, scale); + } + } + + ctx.save(); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + if (info.angle) { + ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); + ctx.rotate(info.angle); + ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); + } else { + ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); + } + ctx.restore(); + } + + function drawSurfaceLightBleed(items, lightSources, time) { + // Kept as a no-op compatibility stub. Reflections are now drawn per sprite + // immediately after that sprite, so light overlays preserve draw order. } function spawnGroundStepParticles(item, time, tile) { @@ -3374,20 +3831,22 @@ if (view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles) return; for (const item of items) { - const emitters = item.asset.meta?.particlePixels || []; - if (!emitters.length) continue; + const legacyEmitters = item.asset.meta?.particlePixels || []; + if (!legacyEmitters.length) continue; const info = getSpriteDrawInfo(item, time, false); const scale = item.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - for (const emitter of emitters) { - if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles || Math.random() > 0.018) continue; + for (const emitter of legacyEmitters) { + if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles || Math.random() > 0.012) continue; const ex = item.asset.category === 'dynamic' && info.side === 'left' ? item.asset.size - 1 - emitter.x : emitter.x; const wx = info.drawX + (ex + 0.5) * scale; const wy = info.drawY + (emitter.y + 0.5) * scale; + const velocity = particleVelocityForDirection(emitter.dir || 'up'); natureDriftParticles.push({ x: wx + (Math.random() - 0.5) * 4, y: wy + (Math.random() - 0.5) * 4, - vx: -3 + Math.random() * 6, - vy: 6 + Math.random() * 12, + vx: velocity.vx + (Math.random() - 0.5) * 4, + vy: velocity.vy + (Math.random() - 0.5) * 4, + gravity: 4, started: time, life: 1500 + Math.random() * 1600, color: colorToHex(emitter.c || selectedColorCode), @@ -3397,6 +3856,14 @@ } } + function particleVelocityForDirection(dir) { + const speed = 13; + if (dir === 'down') return { vx: 0, vy: speed }; + if (dir === 'left') return { vx: -speed, vy: 0 }; + if (dir === 'right') return { vx: speed, vy: 0 }; + return { vx: 0, vy: -speed }; + } + function drawNatureDriftParticles(time) { if (!natureDriftParticles.length || view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; ctx.save(); @@ -3406,7 +3873,7 @@ const seconds = (time - particle.started) / 1000; const sway = Math.sin(age * 5.5 + particle.sway) * 3.5; const x = particle.x + particle.vx * seconds + sway; - const y = particle.y + particle.vy * seconds + age * 5; + const y = particle.y + particle.vy * seconds + age * (particle.gravity ?? 5); ctx.globalAlpha = (1 - age) * 0.72; ctx.fillStyle = particle.color; ctx.fillRect(Math.round(x), Math.round(y), 1, 1); @@ -3452,20 +3919,22 @@ - function drawSpriteShadow(drawX, drawY, sprite, phase) { + function drawSpriteShadow(info, phase) { + const { drawX, sprite, pos, anchorY, bob } = info; const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 }; + if ((shadow.alpha || 0) <= 0.001) return; const silhouette = getShadowCanvas(sprite); const contactX = drawX + sprite.width / 2; - const contactY = drawY + sprite.height; + // Keep the mirror line on the ground. When a selected sprite jumps upward, + // the reflected silhouette moves downward by the same amount instead of floating. + const groundY = pos.y + anchorY; + const mirrorOffset = Math.max(0, -(bob || 0)); ctx.save(); ctx.globalAlpha = shadow.alpha; - // The silhouette is vertically flipped in getShadowCanvas(). - // Drawing it at y=0 means the flipped top edge, which was the sprite's bottom edge, - // exactly touches the sprite's bottom/contact line. - ctx.translate(contactX, contactY); + ctx.translate(contactX, groundY); ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); - ctx.drawImage(silhouette, Math.round(-sprite.width / 2), 0); + ctx.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset)); ctx.restore(); } @@ -3565,31 +4034,99 @@ return { pos, asset, objectId: selectedObject.id }; } + + function findRemixSourceObject(asset) { + const ids = [asset?.parentAssetId, asset?.originalAssetId].filter(Boolean); + for (const assetId of ids) { + const placed = (state.placed || []).find((p) => p.assetId === assetId && !state.hiddenObjects?.[p.id]); + if (placed) return { kind: 'static', object: placed, asset: findAsset(assetId), x: placed.x + .5, y: placed.y + .5 }; + const summon = (state.dynamicSummons || []).find((p) => p.assetId === assetId && !state.hiddenObjects?.[p.id]); + if (summon) return { kind: 'dynamic', object: summon, asset: findAsset(assetId), x: summon.homeX + .5, y: summon.homeY + .5 }; + } + return null; + } + + function centerViewOnWorldCoord(x, y, zoom = null) { + const pos = tileToWorld(x, y); + pos.y -= getLiftAtCoord(x, y); + if (zoom != null) view.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM); + view.x = cw / 2 - pos.x * view.zoom; + view.y = ch / 2 - (pos.y - 32) * view.zoom; + render(performance.now()); + } + + function teleportToRemixSource() { + if (!selectedObject) return; + const asset = findAsset(selectedObject.assetId); + const source = findRemixSourceObject(asset); + if (!source || !source.object || !source.asset) { + toast('Remix source is not placed on the island right now.'); + return; + } + selectWorldObject(source.kind, source.object.id, source.asset.id, performance.now()); + centerViewOnWorldCoord(source.x, source.y, Math.max(view.zoom, 1.05)); + toast(`Teleported to ${source.asset.name || 'remix source'}.`); + } + + function toggleBubbleMenu() { + if (!els.bubbleMenuActions) return; + els.bubbleMenuActions.hidden = !els.bubbleMenuActions.hidden; + } + + function getSelectedDrawableItem(time = performance.now()) { + if (!selectedObject) return null; + const items = getDrawableItems(time, getViewportWorldRect(512)); + return items.find((item) => item.source?.id === selectedObject.id && item.asset?.id === selectedObject.assetId) || null; + } + + function getSelectedBubbleAnchor(time = performance.now()) { + const item = getSelectedDrawableItem(time); + if (!item) return null; + const info = getSpriteDrawInfo(item, time, true); + const topPad = Math.max(14, Math.min(30, info.sprite.height * 0.18)); + return { + x: info.drawX + info.sprite.width / 2, + y: info.drawY - topPad, + asset: item.asset, + objectId: item.source?.id || selectedObject.id + }; + } + function updateSelectionBubble(time) { if (!els.selectionBubble) return; const selected = getSelectedWorldPosition(); if (!selected) { els.selectionBubble.hidden = true; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } - const { pos, asset, objectId } = selected; - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const topOffset = Math.max(18, asset.size * scale + 26); - const sx = pos.x * view.zoom + view.x; - const sy = (pos.y - topOffset) * view.zoom + view.y; - if (sx < -100 || sy < -100 || sx > cw + 100 || sy > ch + 100) { + const anchor = getSelectedBubbleAnchor(time) || { x: selected.pos.x, y: selected.pos.y - Math.max(48, (selected.asset.size || 8) * 2), asset: selected.asset, objectId: selected.objectId }; + const { asset, objectId } = anchor; + const sx = anchor.x * view.zoom + view.x; + const sy = anchor.y * view.zoom + view.y; + if (sx < -120 || sy < -160 || sx > cw + 120 || sy > ch + 120) { els.selectionBubble.hidden = true; + if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } els.selectionBubble.hidden = false; - els.selectionBubble.style.transform = `translate(${Math.round(sx)}px, ${Math.round(sy)}px) translate(-50%, -100%)`; + const bubbleX = clamp(sx, 96, cw - 96); + const bubbleY = clamp(sy, 28, ch - 118); + els.selectionBubble.style.left = `${Math.round(bubbleX)}px`; + els.selectionBubble.style.top = `${Math.round(bubbleY)}px`; + els.selectionBubble.style.transform = 'translate(-50%, -100%)'; els.bubbleName.textContent = asset.name || 'Untitled'; els.bubbleAuthor.textContent = `by ${asset.author || 'Local Artist'}`; const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null; + const remixSource = findRemixSourceObject(asset); if (els.bubbleRemixFrom) { els.bubbleRemixFrom.hidden = !parent; els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : ''; } + if (els.bubbleTeleport) { + els.bubbleTeleport.hidden = !remixSource; + els.bubbleTeleport.textContent = remixSource?.asset ? `Teleport to ${remixSource.asset.name || 'source'}` : 'Teleport to source'; + } if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`; const votes = getObjectVoteCounts(objectId); const voter = currentVoterKey(); @@ -3808,20 +4345,22 @@ ctx.restore(); } - function drawLightSources(sources, darkness) { + function drawLightSources(sources, darkness, time = performance.now()) { ctx.save(); for (const source of sources) { const sx = source.x * view.zoom + view.x; const sy = source.y * view.zoom + view.y; - const radius = source.radius * view.zoom; - const auraRadius = radius * 1.28; + const flicker = 0.94 + pseudoNoise((time * 0.0017) + (source.flickerSeed || 0)) * 0.12; + const radius = source.radius * view.zoom * flicker; + const intensity = 0.68 + (flicker - 0.94) * 0.28; + const auraRadius = radius * 1.12; if (sx < -auraRadius || sy < -auraRadius || sx > cw + auraRadius || sy > ch + auraRadius) continue; // Light cores are drawn after the night overlay and do not inherit night darkness. ctx.globalCompositeOperation = 'source-over'; const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48)); - core.addColorStop(0, hexToRgba(source.color, .84)); - core.addColorStop(.34, hexToRgba(source.color, .44)); + core.addColorStop(0, hexToRgba(source.color, .48 * intensity)); + core.addColorStop(.34, hexToRgba(source.color, .22 * intensity)); core.addColorStop(1, hexToRgba(source.color, .00)); ctx.fillStyle = core; ctx.beginPath(); @@ -3830,8 +4369,8 @@ ctx.globalCompositeOperation = 'lighter'; const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, auraRadius); - glow.addColorStop(0, hexToRgba(source.color, .22)); - glow.addColorStop(.42, hexToRgba(source.color, .10)); + glow.addColorStop(0, hexToRgba(source.color, .13 * intensity)); + glow.addColorStop(.42, hexToRgba(source.color, .05 * intensity)); glow.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = glow; ctx.beginPath(); @@ -3842,10 +4381,14 @@ } function getSpriteCanvas(asset, side) { - const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}` : 'day'; + const lightsOn = areNightLightsActive(renderPhase); + const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}` : 'day:unlit'; const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; if (spriteCache.has(key)) return spriteCache.get(key); const pixels = getAssetPixels(asset, side); + const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); + const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths; + const lights = lightsOn ? getAssetLightPointsForSide(asset, side) : []; const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const canvas = document.createElement('canvas'); canvas.width = asset.size * scale; @@ -3856,7 +4399,8 @@ for (let x = 0; x < asset.size; x++) { const color = pixels[y * asset.size + x]; if (!color) continue; - c.fillStyle = shadeAssetPixelColor(colorToHex(color), getAssetDepth(asset, x, y, side), x, y, asset.size, renderPhase); + const depth = depths[y * asset.size + x] || 0; + c.fillStyle = shadeAssetPixelColor(colorToHex(color), depth, x, y, asset.size, renderPhase, pixels, depths, lights); c.fillRect(x * scale, y * scale, scale, scale); } } @@ -4014,23 +4558,33 @@ return canvas; } - function drawCoastalFoam(time) { + function drawCoastalFoam(time) { return; + /* disabled by request */ + if (!terrainCache?.coastTiles?.length || !coastalFoamTextures) return; const rect = getViewportWorldRect(64); + const t = time * 0.001; for (const tile of terrainCache.coastTiles) { const pos = tileToWorld(tile.x, tile.y); - if (pos.x + 22 < rect.left || pos.x - 22 > rect.right || pos.y + 18 < rect.top || pos.y - 18 > rect.bottom) continue; + if (pos.x + 28 < rect.left || pos.x - 28 > rect.right || pos.y + 24 < rect.top || pos.y - 24 > rect.bottom) continue; + const seed = (tile.x * 17.17 + tile.y * 7.91); + const drift = Math.sin(t * 2.4 + seed) * 5.0; + const bob = Math.cos(t * 2.9 + seed * .7) * 2.1; + const crawl = Math.sin(t * 1.25 + seed * .41) * 3.4; + const pulse = 0.68 + Math.sin(t * 3.1 + seed * .31) * 0.32; const baseY = pos.y + TILE_H * 0.42; ctx.save(); - ctx.globalAlpha = 0.34; - ctx.translate(pos.x, baseY); - ctx.rotate(time / 12000 + (tile.x + tile.y) * 0.03); + ctx.globalAlpha = 0.20 + 0.18 * pulse; + ctx.translate(pos.x + drift, baseY + bob + crawl * .25); + ctx.rotate(time / 9500 + (tile.x + tile.y) * 0.035); + ctx.scale(0.92 + pulse * 0.12, 0.86 + pulse * 0.10); ctx.drawImage(coastalFoamTextures.a, -12, -12); ctx.restore(); ctx.save(); - ctx.globalAlpha = 0.22; - ctx.translate(pos.x + 1, baseY + 1); - ctx.rotate(-(time / 16500) + (tile.x - tile.y) * 0.025); + ctx.globalAlpha = 0.14 + 0.12 * (1 - pulse); + ctx.translate(pos.x - drift * 0.65 + crawl * .35, baseY + 1 - bob * 0.7); + ctx.rotate(-(time / 12500) + (tile.x - tile.y) * 0.03); + ctx.scale(0.78 + (1 - pulse) * 0.18, 0.78 + pulse * 0.12); ctx.drawImage(coastalFoamTextures.b, -12, -12); ctx.restore(); } @@ -4184,7 +4738,7 @@ eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], sync: input.sync || { lastEventId: null }, settings: { ...defaultVisualSettings(), ...(input.settings || {}) }, - account: input.account || null, + account: normalizeAccount(input.account), publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [] }; } @@ -4220,15 +4774,15 @@ .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - const particlePixels = Array.isArray(meta.particlePixels) + const legacyParticlePixels = Array.isArray(meta.particlePixels) ? meta.particlePixels - .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff') })) + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size, particlePixels); + return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size, legacyParticlePixels); } - function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticlePixels = []) { + function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticle = null) { const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); const hasDepth = normalizedDepth.some(Boolean); const lightPixelsClean = Array.isArray(sourceLightPixels) @@ -4236,19 +4790,25 @@ .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; - const particlePixelsClean = Array.isArray(sourceParticlePixels) - ? sourceParticlePixels - .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff') })) + const legacyParticlePixels = Array.isArray(sourceParticle) + ? sourceParticle + .map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; + const particleSource = !Array.isArray(sourceParticle) && sourceParticle + ? sourceParticle + : (legacyParticlePixels.length ? { enabled: true, c: legacyParticlePixels[0].c, dir: legacyParticlePixels[0].dir || 'up' } : null); + const particleClean = normalizeParticleConfig(particleSource || { enabled: false, c: nearestPaletteCode('#ffffff'), dir: 'up' }, size); + const particleCells = legacyParticlePixels.map((p) => ({ x: p.x, y: p.y, c: PALETTE_BY_CODE[p.c] ? p.c : nearestPaletteCode('#ffffff'), dir: ['up','down','left','right'].includes(p.dir) ? p.dir : (particleClean.dir || 'up') })); const hasLight = lightPixelsClean.length > 0; - const hasParticles = particlePixelsClean.length > 0; + const hasParticles = particleCells.length > 0; return { hasLight, lightPixels: hasLight ? lightPixelsClean : [], lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, hasParticles, - particlePixels: hasParticles ? particlePixelsClean : [], + particlePixels: hasParticles ? particleCells : [], + particleConfig: { enabled: hasParticles, c: particleCells[0]?.c || particleClean.c, dir: particleCells[0]?.dir || particleClean.dir }, depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null, door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null }; @@ -4370,7 +4930,7 @@ createdAt: Date.now(), updatedAt: Date.now(), author: 'Island Team', - meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particlePixels || []) + meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particleConfig || meta.particlePixels || []) }; asset.contentHash = computeAssetContentHash(asset); return asset; @@ -4630,6 +5190,7 @@ asset.meta?.depthPixels || '', JSON.stringify(asset.meta?.lightPixels || []), JSON.stringify(asset.meta?.particlePixels || []), + JSON.stringify(asset.meta?.particleConfig || null), asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' ].join('|'); return `fnv1a:${fnv1a(payload)}`; @@ -4764,23 +5325,123 @@ return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase); } - function shadeAssetPixelColor(hex, depth, x, y, size, phase) { + function shadeAssetPixelColor(hex, depth, x, y, size, phase, pixels = null, depths = null, lights = []) { const rgb = parseHex(hex); if (!rgb) return hex; const activePhase = phase || renderPhase || getPhase(); const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1); const vertical = 1 - (y / Math.max(1, size - 1)); const horizontal = ((x / Math.max(1, size - 1)) - 0.5) * dirX; - const sunlight = (activePhase?.key === 'night' ? 8 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 12 : 16); - const depthBoost = depth * sunlight; - const exposure = (vertical * 0.7 + horizontal * 0.5) * (activePhase?.key === 'night' ? 9 : 13); - const amt = depthBoost + exposure; - const r = clamp(rgb.r + amt, 0, 255); - const g = clamp(rgb.g + amt, 0, 255); - const b = clamp(rgb.b + amt, 0, 255); + const sunlight = (activePhase?.key === 'night' ? 6 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 9 : 11); + const depthBoost = depth * sunlight * 0.35; + const exposure = (vertical * 0.62 + horizontal * 0.5) * (activePhase?.key === 'night' ? 6 : 10); + const edgeBoost = getDepthEdgeLightBoost(depth, x, y, size, pixels, depths, lights, activePhase); + let r = rgb.r + depthBoost + exposure + edgeBoost; + let g = rgb.g + depthBoost + exposure + edgeBoost; + let b = rgb.b + depthBoost + exposure + edgeBoost; + + if (areNightLightsActive(activePhase) && lights?.length) { + for (const light of lights) { + const lc = parseHex(light.c || '#ffd86a'); + if (!lc) continue; + const dist = Math.hypot((light.x + 0.5) - (x + 0.5), (light.y + 0.5) - (y + 0.5)); + const reach = Math.max(2.25, size * 0.42); + const t = 1 - clamp(dist / reach, 0, 1); + if (t <= 0) continue; + const edge = getDepthLightFacing(depth, x, y, size, pixels, depths, light.x, light.y); + const depthFactor = depth > 0 ? 1.08 + edge * 0.30 : depth < 0 ? 0.72 + edge * 0.16 : 0.54 + edge * 0.08; + const strength = t * t * (10 + Math.abs(depth) * 7) * depthFactor; + const mix = Math.min(0.52, 0.12 + t * (depth ? 0.24 : 0.18)); + r = lerp(r, lc.r + strength, mix); + g = lerp(g, lc.g + strength * 0.82, mix * 0.94); + b = lerp(b, lc.b + strength * 0.74, mix * 0.88); + } + } + + r = clamp(r, 0, 255); + g = clamp(g, 0, 255); + b = clamp(b, 0, 255); return `#${Math.round(r).toString(16).padStart(2,'0')}${Math.round(g).toString(16).padStart(2,'0')}${Math.round(b).toString(16).padStart(2,'0')}`; } + + function mirrorDepthPixels(depths, size) { + const source = normalizeDepthPixels(depths || [], size); + const out = Array(size * size).fill(0); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) out[y * size + (size - 1 - x)] = source[y * size + x] || 0; + } + return out; + } + + function getAssetLightPointsForSide(asset, side = 'right') { + const size = asset.size || 1; + const lights = Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels : []; + return lights.map((p) => ({ + x: asset.category === 'dynamic' && side === 'left' ? size - 1 - p.x : p.x, + y: p.y, + c: colorToHex(p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a')) + })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)); + } + + function isObjectOutlinePixel(pixels, x, y, size) { + if (!pixels || !pixels[y * size + x]) return false; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + if (nx < 0 || ny < 0 || nx >= size || ny >= size) return true; + if (!pixels[ny * size + nx]) return true; + } + return false; + } + + function collectDepthEdgeDirs(x, y, size, pixels, depths, depth) { + if (!pixels || !depths) return []; + const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; + const edgeDirs = []; + for (const [dx, dy] of dirs) { + const nx = x + dx, ny = y + dy; + const neighborInside = nx >= 0 && ny >= 0 && nx < size && ny < size; + const neighborOpaque = neighborInside && pixels[ny * size + nx]; + const neighborDepth = neighborOpaque ? (depths[ny * size + nx] || 0) : 0; + if (!neighborOpaque || neighborDepth !== depth) edgeDirs.push([dx, dy]); + } + return edgeDirs; + } + + function getDepthLightFacing(depth, x, y, size, pixels, depths, lightX, lightY) { + if (!depth || !pixels || !depths) return 0; + const edgeDirs = collectDepthEdgeDirs(x, y, size, pixels, depths, depth); + if (!edgeDirs.length) return 0; + const lx = lightX - x; + const ly = lightY - y; + const len = Math.hypot(lx, ly) || 1; + const vx = lx / len, vy = ly / len; + let bestDot = -1; + for (const [dx, dy] of edgeDirs) bestDot = Math.max(bestDot, dx * vx + dy * vy); + return depth > 0 ? Math.max(0, bestDot) : Math.max(0, -bestDot); + } + + function getDepthEdgeLightBoost(depth, x, y, size, pixels, depths, lights, phase) { + if (!depth || !pixels || !depths || !areNightLightsActive(phase) || !lights?.length) return 0; + let best = null, bestD = Infinity; + for (const light of lights) { + const d = Math.hypot(light.x - x, light.y - y); + if (d < bestD) { bestD = d; best = light; } + } + if (!best) return 0; + const facing = getDepthLightFacing(depth, x, y, size, pixels, depths, best.x, best.y); + if (!facing) return 0; + const outerEdge = collectDepthEdgeDirs(x, y, size, pixels, depths, depth).some(([dx, dy]) => { + const nx = x + dx, ny = y + dy; + return nx < 0 || ny < 0 || nx >= size || ny >= size || !pixels[ny * size + nx]; + }); + const distanceFalloff = 1 - clamp(bestD / Math.max(2.25, size * 0.44), 0, 1); + if (distanceFalloff <= 0) return 0; + const strength = outerEdge ? 0.64 : 1; + return facing * strength * distanceFalloff * distanceFalloff * (depth > 0 ? 20 : 14); + } + function resizePixels(source, oldSize, newSize) { const out = blankPixels(newSize); const min = Math.min(oldSize, newSize); diff --git a/index.html b/index.html index 3d6d6f2..eefd087 100644 --- a/index.html +++ b/index.html @@ -9,41 +9,44 @@
+
Left-click: select · Right-click: clear
- + -
-
+
+
-
Local prototype / Draw pixels, summon them to islands.
+
A rotating island exhibition for tiny pixel works.
- -
-
Day
-
-
10 min = 1 day
+
+
+ + + +
+ + Save + Place creates a local ID, name, and password. Change the password after creation.
- +
+
+ +
+
Day
+ +
@@ -55,9 +58,9 @@
@@ -102,33 +105,49 @@
- - - - - - - - - + + + +
- - - - - - + + +
+
- - - - - - - - + + + + + + + +
Palette color is used for pixels, lights, particles, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.
@@ -138,9 +157,7 @@
Finish
- - - +
@@ -149,7 +166,7 @@
-
Library
+
Collection

Your works are permanent here. The island is a temporary exhibition: place your own new or past works; remix only copies lineage and pixels to the canvas.

@@ -162,10 +179,10 @@
-
+
Local save
-

Works are durable library assets; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.

+

Works are durable collection works; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.

@@ -221,7 +238,11 @@
- + + +
+ + diff --git a/js/phase2-sync.js b/js/phase2-sync.js index 1ae0e8e..0300bb1 100644 --- a/js/phase2-sync.js +++ b/js/phase2-sync.js @@ -2,21 +2,134 @@ 'use strict'; const root = window.PixelIslandModules ||= {}; - const FORMAT = 'pixel-island-phase2-compact-v1'; + const FORMAT = 'pixel-island-phase2-compact-v2'; + const FORMAT_V1 = 'pixel-island-phase2-compact-v1'; const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1'; - const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1'; + const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v2'; + const ASSET_BUNDLE_FORMAT_V1 = 'pixel-island-phase2-asset-bundle-v1'; const EVENT_LOG_LIMIT = 300; const DB_NAME = 'pixel-island-phase2-cache'; const DB_VERSION = 1; const ASSET_STORE = 'assets'; const SNAPSHOT_STORE = 'snapshots'; + const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const PACK6_CHARS = `.${COLOR_CODES}`; function isCompactState(value) { - return Boolean(value && value.format === FORMAT && Array.isArray(value.assets)); + return Boolean(value && (value.format === FORMAT || value.format === FORMAT_V1) && Array.isArray(value.assets)); } function isAssetBundle(value) { - return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets)); + return Boolean(value && (value.format === ASSET_BUNDLE_FORMAT || value.format === ASSET_BUNDLE_FORMAT_V1) && Array.isArray(value.assets)); + } + + function bytesToBase64(bytes) { + if (!bytes || !bytes.length) return ''; + if (typeof btoa === 'function') { + let binary = ''; + const chunk = 8192; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.slice(i, i + chunk)); + } + return btoa(binary); + } + if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64'); + return ''; + } + + function base64ToBytes(value) { + const text = String(value || ''); + if (!text) return []; + if (typeof atob === 'function') { + const binary = atob(text); + const out = new Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) & 255; + return out; + } + if (typeof Buffer !== 'undefined') return Array.from(Buffer.from(text, 'base64')); + return []; + } + + function pack6Index(ch) { + const index = PACK6_CHARS.indexOf(ch || '.'); + return index >= 0 && index < 64 ? index : 0; + } + + function unpack6Index(value) { + return PACK6_CHARS[value] || '.'; + } + + function canPack6(text) { + for (const ch of String(text || '')) if (PACK6_CHARS.indexOf(ch) < 0) return false; + return true; + } + + function pack6Text(input) { + const text = String(input || ''); + if (!text) return ''; + let buffer = 0; + let bitCount = 0; + const bytes = []; + for (const ch of text) { + buffer = (buffer << 6) | pack6Index(ch); + bitCount += 6; + while (bitCount >= 8) { + bitCount -= 8; + bytes.push((buffer >> bitCount) & 255); + buffer &= (1 << bitCount) - 1; + } + } + if (bitCount > 0) bytes.push((buffer << (8 - bitCount)) & 255); + return bytesToBase64(bytes); + } + + function unpack6Text(input, length) { + const target = Math.max(0, Number(length) || 0); + if (!target) return ''; + const bytes = base64ToBytes(input); + let buffer = 0; + let bitCount = 0; + let out = ''; + for (const byte of bytes) { + buffer = (buffer << 8) | (byte & 255); + bitCount += 8; + while (bitCount >= 6 && out.length < target) { + bitCount -= 6; + out += unpack6Index((buffer >> bitCount) & 63); + buffer &= (1 << bitCount) - 1; + } + if (out.length >= target) break; + } + return (out + '.'.repeat(target)).slice(0, target); + } + + function packBitMask(mask) { + const bits = Array.from(mask || [], Boolean); + if (!bits.length) return ''; + const bytes = []; + let current = 0; + for (let i = 0; i < bits.length; i++) { + current = (current << 1) | (bits[i] ? 1 : 0); + if (i % 8 === 7) { + bytes.push(current & 255); + current = 0; + } + } + const rest = bits.length % 8; + if (rest) bytes.push((current << (8 - rest)) & 255); + return bytesToBase64(bytes); + } + + function unpackBitMask(input, length) { + const target = Math.max(0, Number(length) || 0); + const bytes = base64ToBytes(input); + const out = []; + for (const byte of bytes) { + for (let bit = 7; bit >= 0 && out.length < target; bit--) out.push(Boolean((byte >> bit) & 1)); + if (out.length >= target) break; + } + while (out.length < target) out.push(false); + return out; } function rleEncode(input) { @@ -61,7 +174,17 @@ return (source + emptyChar.repeat(total)).slice(0, total); } - function cropPlane(encoded, size, emptyChar = '.') { + function chooseSmallest(candidates) { + let best = candidates[0]; + let bestSize = JSON.stringify(best).length; + for (const candidate of candidates.slice(1)) { + const size = JSON.stringify(candidate).length; + if (size < bestSize) { best = candidate; bestSize = size; } + } + return best; + } + + function cropPlane(encoded, size, emptyChar = '.', options = {}) { const text = normalizeEncodedPlane(encoded, size, emptyChar); let minX = size; let minY = size; @@ -81,42 +204,55 @@ const w = maxX - minX + 1; const h = maxY - minY + 1; let cropped = ''; - for (let y = minY; y <= maxY; y++) { - cropped += text.slice(y * size + minX, y * size + minX + w); - } - const rle = rleEncode(cropped); - return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped }; + for (let y = minY; y <= maxY; y++) cropped += text.slice(y * size + minX, y * size + minX + w); + const candidates = [ + { b: [minX, minY, w, h], e: 'raw', v: cropped }, + { b: [minX, minY, w, h], e: 'rle', v: rleEncode(cropped) } + ]; + if (options.bitPack && canPack6(cropped)) candidates.push({ b: [minX, minY, w, h], e: 'bp6', v: pack6Text(cropped), n: cropped.length }); + return chooseSmallest(candidates); } - function expandPlane(packed, size, emptyChar = '.') { + function expandPlane(packed, size, emptyChar = '.', baseEncoded = null) { const total = Math.max(1, Number(size) || 1) ** 2; const out = Array(total).fill(emptyChar); if (!packed || !packed.b) return out.join(''); const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0)); - const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || ''); + let value = ''; + if (packed.e === 'rle') value = rleDecode(packed.v); + else if (packed.e === 'bp6') value = unpack6Text(packed.v, packed.n || (w * h)); + else value = String(packed.v || ''); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const src = y * w + x; const dx = x0 + x; const dy = y0 + y; - if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) { - out[dy * size + dx] = value[src] || emptyChar; - } + if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) out[dy * size + dx] = value[src] || emptyChar; } } return out.join(''); } - function packAsset(asset) { + function planeForAsset(asset) { + const size = Math.max(1, Number(asset?.size) || 16); + return normalizeEncodedPlane(asset?.faces?.right || asset?.pixels || '', size, '.'); + } + + function selectPixelPlanePack(asset, assetMap) { + const size = Math.max(1, Number(asset.size) || 16); + const right = planeForAsset(asset); + return cropPlane(right, size, '.', { bitPack: true }); + } + + function packAsset(asset, assetMap = null) { const size = Math.max(1, Number(asset.size) || 16); const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; - const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.'); const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : ''; const lights = Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2]) : []; const particles = Array.isArray(asset.meta?.particlePixels) - ? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '']).filter((p) => p[2]) + ? asset.meta.particlePixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || '', p.dir || 'up']).filter((p) => p[2]) : []; const meta = {}; if (depth && /[1\-]/.test(depth)) meta.d = cropPlane(depth, size, '.'); @@ -124,6 +260,10 @@ if (particles.length) meta.pt = particles; if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor; if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0]; + if (asset.meta?.particleConfig?.enabled && !particles.length) { + const pc = asset.meta.particleConfig; + meta.pc = [pc.c || '', pc.dir || 'up', null]; + } return { id: asset.id, @@ -132,7 +272,7 @@ c: category, t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), s: size, - p: cropPlane(right, size, '.'), + p: selectPixelPlanePack(asset, assetMap), f: category === 'dynamic' ? { l: 'mirror' } : null, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null, @@ -143,24 +283,29 @@ }; } - function unpackAsset(packed) { + function unpackAsset(packed, assetById = null) { if (!packed || !packed.id) return null; const size = Math.max(1, Number(packed.s || packed.size) || 16); const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static'; const pixels = expandPlane(packed.p, size, '.'); + if (pixels == null) return null; const metaPacked = packed.m || {}; const lightPixels = Array.isArray(metaPacked.l) ? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' })) : []; const particlePixels = Array.isArray(metaPacked.pt) - ? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a' })) + ? metaPacked.pt.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || 'a', dir: p[3] || 'up' })) : []; + const particleConfig = Array.isArray(metaPacked.pc) + ? { enabled: true, c: metaPacked.pc[0] || 'a', dir: metaPacked.pc[1] || 'up', range: Array.isArray(metaPacked.pc[2]) ? { x: metaPacked.pc[2][0] || 0, y: metaPacked.pc[2][1] || 0, w: metaPacked.pc[2][2] || 0, h: metaPacked.pc[2][3] || 0 } : null } + : null; const meta = { hasLight: lightPixels.length > 0, lightPixels, lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null, - hasParticles: particlePixels.length > 0, + hasParticles: Boolean(particleConfig) || particlePixels.length > 0, particlePixels, + particleConfig, depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null, door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null }; @@ -182,6 +327,10 @@ }; } + function unpackAssets(rows) { + return (Array.isArray(rows) ? rows : []).map((row) => unpackAsset(row)).filter(Boolean); + } + function packPlacement(item) { const meta = {}; if (item.publishedAt) meta.pu = item.publishedAt; @@ -208,12 +357,18 @@ return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1, publishedAt: meta.pu || row[4], status: meta.st || 'active' }; } + function assetMapFor(assets) { + return new Map((assets || []).map((asset) => [asset.id, asset])); + } + function compactState(state) { + const assets = Array.isArray(state.assets) ? state.assets : []; + const map = assetMapFor(assets); return { - schema: 3, + schema: 4, format: FORMAT, authorName: state.authorName || 'Local Artist', - assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [], + assets: assets.map((asset) => packAsset(asset, map)), placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [], dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [], objectVotes: state.objectVotes || {}, @@ -233,9 +388,9 @@ function expandState(input) { if (!isCompactState(input)) return input; return { - schema: 3, + schema: input.schema || 4, authorName: input.authorName || 'Local Artist', - assets: input.assets.map(unpackAsset).filter(Boolean), + assets: unpackAssets(input.assets), placed: (input.placed || []).map(unpackPlacement), dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic), objectVotes: input.objectVotes || {}, @@ -253,7 +408,7 @@ } function compactSizeReport(state) { - const full = JSON.stringify({ ...state, schema: 3 }); + const full = JSON.stringify({ ...state, schema: 4 }); const compact = JSON.stringify(compactState(state)); return { fullBytes: full.length, @@ -266,12 +421,12 @@ } function assetManifest(state) { - return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype })); + return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype, pa: asset.parentAssetId || null, oa: asset.originalAssetId || null })); } function makeSnapshot(state, worldId = 'local-main') { return { - schema: 3, + schema: 4, format: SNAPSHOT_FORMAT, worldId, createdAt: Date.now(), @@ -284,13 +439,14 @@ function makeAssetBundle(state, assetIds) { const wanted = new Set(assetIds || []); - const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset); - return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets }; + const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)); + const map = assetMapFor(assets); + return { schema: 4, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets: assets.map((asset) => packAsset(asset, map)) }; } function unpackAssetBundle(bundle) { if (!isAssetBundle(bundle)) return []; - return bundle.assets.map(unpackAsset).filter(Boolean); + return unpackAssets(bundle.assets); } function findMissingAssetIds(snapshot, knownAssetIds) { @@ -306,6 +462,10 @@ return makeEvent('asset.upsert', { asset: packAsset(asset) }); } + function createAssetDeleteEvent(assetId) { + return makeEvent('asset.delete', { assetId }); + } + function createObjectUpsertEvent(kind, object) { return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) }); } @@ -314,15 +474,34 @@ return makeEvent('object.delete', { kind, objectId }); } + function deleteAssetOnly(state, assetId) { + if (!state || !assetId) return state; + const removedObjectIds = new Set(); + for (const item of state.placed || []) if (item.assetId === assetId) removedObjectIds.add(item.id); + for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removedObjectIds.add(item.id); + state.assets = (state.assets || []).filter((asset) => asset.id !== assetId); + state.placed = (state.placed || []).filter((item) => item.assetId !== assetId); + state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId); + delete state.assetVotes?.[assetId]; + delete state.hiddenAssets?.[assetId]; + for (const id of removedObjectIds) delete state.objectVotes?.[id], delete state.hiddenObjects?.[id]; + state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); + return state; + } + function applyEvent(state, event) { if (!state || !event) return state; if (event.type === 'asset.upsert' && event.asset) { - const asset = unpackAsset(event.asset); + const byId = assetMapFor(state.assets || []); + const asset = unpackAsset(event.asset, byId); if (!asset) return state; const index = (state.assets || []).findIndex((a) => a.id === asset.id); if (index >= 0) state.assets[index] = asset; else (state.assets ||= []).unshift(asset); } + if (event.type === 'asset.delete' && event.assetId) { + deleteAssetOnly(state, event.assetId); + } if (event.type === 'object.upsert') { if (event.kind === 'dynamic') { const object = unpackDynamic(event.object); @@ -381,7 +560,7 @@ request.onerror = () => resolve(null); }))); db.close(); - return rows.filter(Boolean).map(unpackAsset).filter(Boolean); + return unpackAssets(rows.filter(Boolean)); } async function cacheSnapshot(snapshot) { @@ -398,17 +577,24 @@ root.Phase2Sync = { FORMAT, + FORMAT_V1, SNAPSHOT_FORMAT, ASSET_BUNDLE_FORMAT, + ASSET_BUNDLE_FORMAT_V1, EVENT_LOG_LIMIT, isCompactState, isAssetBundle, rleEncode, rleDecode, + pack6Text, + unpack6Text, + packBitMask, + unpackBitMask, cropPlane, expandPlane, packAsset, unpackAsset, + unpackAssets, compactState, expandState, compactSizeReport, @@ -419,8 +605,10 @@ findMissingAssetIds, makeEvent, createAssetUpsertEvent, + createAssetDeleteEvent, createObjectUpsertEvent, createObjectDeleteEvent, + deleteAssetOnly, applyEvent, cacheAssets, readCachedAssets, diff --git a/server/__pycache__/compression_lab.cpython-313.pyc b/server/__pycache__/compression_lab.cpython-313.pyc index ced65c6cda586629e02f6c7e00531eef1d74f77a..9d230cc2485e8a9929eb507d1056a90a38d5840f 100644 GIT binary patch delta 26 gcmbO)a#@(`GcPX}0}!z7*vRF<&M3KgK6?Wb09uCzcmMzZ delta 106 zcmcaCJYR(CGcPX}0}xE#zLCp=-8WS~H?KrLC9xz?zbL;Xu_QA;FTOm#C|f_XI43bL zMYkZcA~i?1xHLC6KQFaNx6DizrcAdWKPNM}Qa2|*IT5HLF)dYpvMi(I<{b8PCIF>D BB`5#@ diff --git a/server/__pycache__/rotation_worker.cpython-313.pyc b/server/__pycache__/rotation_worker.cpython-313.pyc index bcdbb7c700df9307a25433abca86b7a1ad0b6524..40a187ff7163e11fa3311dfa6ee8c7ff20600b35 100644 GIT binary patch delta 28 icmZo(#JGJSBiCnMUM>b8VB4{gON)z9a&seBlN$hXX9sQo delta 104 zcmdnKkg<6YBiCnMUM>b8=-s}NON+}lRX;bcL_a05BvHR8za+6FGe0lBJijPgKeIR| zF)u~8AhRMhN4K~%H#a{owMe(jOc$n1w;(?!Gr3YXCqFq6s3I{fRe!T7SCbn6WB?^f diff --git a/styles.css b/styles.css index f8c9b9a..959cb18 100644 --- a/styles.css +++ b/styles.css @@ -1015,3 +1015,447 @@ body, button, input, select, textarea { font-size: 15px; } .likedCodexItem canvas { width:28px; height:28px; image-rendering:pixelated; } .hiddenReasonText { color:#6f2f3b; font-weight:700; } .exhibitionNote { padding:8px 10px; background:#fff3d9; border:2px solid rgba(36,48,68,.2); margin-bottom:8px; font-size:14px; line-height:1.45; } + + +/* compact account bar / remove right-side HUD */ +.toolHud { display: none !important; } +.topHud { min-height: 50px; padding: 8px 12px; gap: 10px; align-items: center; } +.authorCard { + width: auto !important; + min-width: 0 !important; + max-width: min(520px, 48vw); + display: flex !important; + align-items: center; + gap: 8px; + padding: 5px 7px !important; + white-space: nowrap; +} +.authorCard span { display:inline !important; margin:0; font-size:11px !important; } +.authorCard input { margin-top:0 !important; width:132px; min-height:28px; } +.authorCard small { display:inline !important; margin-top:0 !important; line-height:1.15 !important; font-size:11px !important; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.miniButton { margin-top:0 !important; padding:5px 7px !important; font-size:11px !important; white-space:nowrap; } +.clockCard { width: 190px; padding: 6px 8px; } +.subline { white-space: nowrap; } +.advancedRow { grid-template-columns: repeat(5, max-content) 1fr !important; } +@media (max-width: 900px) { + .topHud { right: 76px; align-items:flex-start; } + .authorCard { max-width: calc(100vw - 110px); flex-wrap: wrap; white-space: normal; } + .authorCard small { max-width: 100%; } +} + +/* 2026 polish pass: make the island the visual default, keep creation and collection one click away. */ +.islandTopBar { + top: 12px; + left: 50%; + right: auto; + width: min(560px, calc(100vw - 24px)); + min-height: 0 !important; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px !important; + background: rgba(255, 253, 245, .78) !important; + border: 1px solid rgba(36, 48, 68, .24) !important; + box-shadow: 0 6px 18px rgba(22, 31, 46, .12) !important; + backdrop-filter: blur(10px); + pointer-events: auto; +} +.brandBlock { min-width: 0; } +.islandTopBar .logo { font-size: 18px; line-height: 1; white-space: nowrap; } +.islandTopBar .subline { + margin-top: 2px; + max-width: 300px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 11px; + opacity: .72; +} +.topActions { display: flex; gap: 6px; align-items: center; } +.topButton { + padding: 7px 10px !important; + border-width: 1px !important; + box-shadow: none !important; + background: rgba(255, 253, 245, .86) !important; + font-size: 12px !important; +} +.topButton.primary { background: var(--mint) !important; } +.utilityControl { display: none !important; } +.edgeAdd { display: none !important; } +.devOnly { display: none !important; } + +/* Beginner-first editor: show only Pencil/Erase/Fill/Pick + Undo/Redo until Advanced is enabled. */ +.advancedTool { display: none !important; } +.studioDrawer.advancedTools .advancedTool { display: inline-flex !important; } +.studioDrawer.advancedTools .toolRow .advancedTool { display: block !important; } +.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorHistoryRow { grid-template-columns: repeat(6, minmax(0, 1fr)) !important; } +#editHint { opacity: .78; } +#editHint::after { content: " Advanced reveals line, rectangle, selection, flip, light, depth, and particles."; } + +/* Let the island breathe under the lighter navigation. */ +#worldCanvas { background: #8dd7ff; } +.selectionBubble { + border-width: 2px !important; + box-shadow: 0 8px 20px rgba(22,31,46,.16) !important; + background: rgba(255,253,245,.94) !important; +} +@media (max-width: 720px) { + .islandTopBar { width: calc(100vw - 16px); top: 8px; } + .islandTopBar .subline { display: none; } + .topButton { padding: 7px 8px !important; } +} + +/* v8 account, editor, and placement refinements */ +.islandClickHint { + position: fixed; + left: 14px; + bottom: 14px; + z-index: 24; + padding: 6px 8px; + border: 1px solid rgba(36,48,68,.22); + background: rgba(255,253,245,.72); + box-shadow: 0 5px 16px rgba(22,31,46,.10); + backdrop-filter: blur(8px); + font-size: 11px; + color: rgba(36,48,68,.78); + pointer-events: none; +} +.islandTopBar { + width: min(960px, calc(100vw - 24px)) !important; +} +.accountCard.utilityControl { + display: flex !important; + align-items: center; + gap: 7px; + max-width: 430px !important; + padding: 5px 7px !important; + border: 1px solid rgba(36,48,68,.2) !important; + background: rgba(255,253,245,.68) !important; + box-shadow: none !important; +} +.accountFields { + display: grid; + grid-template-columns: 96px 112px 112px; + gap: 5px; + align-items: end; +} +.accountField { + display: grid; + gap: 2px; + font-size: 9px !important; + line-height: 1; + color: rgba(36,48,68,.72); +} +.accountField input { + width: 100% !important; + min-height: 24px !important; + padding: 3px 5px !important; + font-size: 10px !important; + border-width: 1px !important; + background: rgba(255,255,255,.72); +} +.accountField input[readonly] { opacity: .72; } +.accountCard small { + max-width: 118px !important; + white-space: normal !important; + font-size: 9px !important; + line-height: 1.15 !important; + opacity: .78; +} +.accountCard .miniButton { + font-size: 9px !important; + padding: 4px 5px !important; + border-width: 1px !important; + box-shadow: none !important; +} +.clockCard.utilityControl { display: none !important; } +.editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorToolRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +.advancedToolGroup { + display: grid; + gap: 8px; + margin: 2px 0 8px; +} +.advancedToolGroup[hidden] { display: none !important; } +.advancedDrawRow { grid-template-columns: repeat(5, minmax(0, 1fr)) !important; } +.advancedActionRow { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } +.studioDrawer.advancedTools .advancedTool { display: block !important; } +.toolBrush { background: #e8fff0 !important; } +.toolErase { background: #ffe9e2 !important; } +.toolFill { background: #e7f0ff !important; } +.toolPick { background: #fff0d4 !important; } +.toolUndo, .toolRedo { background: #f2ecff !important; } +.toolLine { background: #e9f7ff !important; } +.toolRect { background: #edf9e3 !important; } +.toolSelect { background: #ffe8f3 !important; } +.toolDoor { background: #f5ead7 !important; } +.toolOutline { background: #fff6d8 !important; } +.toolFlipH, .toolFlipV { background: #eaf2ff !important; } +.toolClearSel { background: #f7e9ff !important; } +.toolAdvanced { background: #f5f1e7 !important; } +.toolLight { background: #fff0a8 !important; } +.toolDepth { background: #dfeeff !important; } +.toolParticle { background: #e8fffb !important; } +.tool.active, button.active { + outline: 2px solid rgba(36,48,68,.55); + outline-offset: -4px; + filter: saturate(1.08); +} +.particleControl { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 7px; + border: 2px solid var(--line); + background: #e8fffb; + box-shadow: 3px 3px 0 rgba(36,48,68,.14); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; +} +.particleControl[hidden] { display: none !important; } +.particleControl select { + min-height: 26px; + padding: 2px 5px; + font-size: 11px; +} +.finishActions { grid-template-columns: 1.4fr 1fr !important; } +.placementPreviewBar span::after { content: " Left-click chooses a tile; right-click cancels selection."; } +@media (max-width: 980px) { + .islandTopBar { width: calc(100vw - 16px) !important; flex-wrap: wrap; justify-content: center; } + .accountCard.utilityControl { order: 3; max-width: 100% !important; } + .accountFields { grid-template-columns: repeat(3, minmax(72px, 1fr)); } + .accountCard small { max-width: 100% !important; } +} +@media (max-width: 560px) { + .accountFields { grid-template-columns: 1fr; } + .finishActions { grid-template-columns: 1fr !important; } + .advancedDrawRow, .advancedActionRow { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } +} + +/* v9 menu and particle-range refinements */ +.cuteMenuButton { + flex: 0 0 auto; + width: 38px; + height: 38px; + padding: 0 !important; + display: grid; + place-items: center; + border: 2px solid rgba(36,48,68,.72) !important; + border-radius: 14px !important; + background: linear-gradient(180deg, #ffe3f0, #ffc3dd) !important; + box-shadow: 3px 3px 0 rgba(36,48,68,.18) !important; + font-size: 19px !important; + line-height: 1 !important; + cursor: pointer; +} +.cuteMenuButton:hover { transform: translateY(-1px); filter: saturate(1.08); } +.cuteMenuButton:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(36,48,68,.18) !important; } +.islandTopBar .brandBlock { margin-right: auto; } +.particleControlGroup { + display: grid !important; + grid-template-columns: minmax(92px, 120px) auto; + align-items: end; + gap: 6px 8px; + max-width: min(100%, 360px); + text-transform: none; +} +.particleControlGroup label { font-size: 10px; font-weight: 900; color: rgba(36,48,68,.76); } +.particleRangeButtons { + display: grid; + grid-template-columns: repeat(2, minmax(74px, 1fr)); + gap: 5px; +} +.particleRangeButtons .tool { padding: 6px 7px !important; font-size: 11px !important; } +#particleRangeStatus { + grid-column: 1 / -1; + font-size: 10px; + color: rgba(36,48,68,.72); + line-height: 1.25; +} +@media (max-width: 560px) { + .particleControlGroup { grid-template-columns: 1fr; } + .particleRangeButtons { grid-template-columns: 1fr 1fr; } +} + +/* v10 bottom DRAW access and remix bottom sheet */ +.drawDockButton { + position: fixed; + z-index: 28; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + min-width: 210px; + min-height: 62px; + padding: 12px 28px !important; + border: 4px solid var(--line) !important; + border-radius: 22px !important; + background: linear-gradient(180deg, #ffe18f, #ff9dbf 62%, #ff7aa8) !important; + color: #253044 !important; + box-shadow: 0 8px 0 rgba(36,48,68,.24), 0 14px 26px rgba(22,31,46,.18) !important; + font-size: 24px !important; + letter-spacing: .06em; + line-height: 1 !important; + cursor: pointer; +} +.drawDockButton:hover { filter: saturate(1.08) brightness(1.02); } +.drawDockButton:active { transform: translate(-50%, 4px); box-shadow: 0 4px 0 rgba(36,48,68,.24), 0 8px 18px rgba(22,31,46,.16) !important; } +.drawDockButton[hidden] { display: none !important; } +.islandTopBar .cuteMenuButton { display: none !important; } +.islandTopBar .brandBlock { margin-right: 0 !important; } +.islandClickHint { bottom: 96px !important; } + +.selectionBubble { + position: absolute !important; + left: 0 !important; + top: 0 !important; + z-index: 36 !important; + min-width: 138px !important; + max-width: 224px !important; + padding: 8px 9px !important; + display: block !important; + text-align: center !important; + border: 3px solid var(--line) !important; + border-radius: 14px !important; + background: rgba(255,253,245,.98) !important; + box-shadow: 0 6px 0 rgba(36,48,68,.18), 0 10px 20px rgba(22,31,46,.15) !important; +} +.selectionBubble[hidden] { display: none !important; } +.selectionBubble::after { + display: block !important; + content: '' !important; + position: absolute; + left: 50%; + bottom: -10px; + width: 14px; + height: 14px; + background: rgba(255,253,245,.98); + border-right: 3px solid var(--line); + border-bottom: 3px solid var(--line); + transform: translateX(-50%) rotate(45deg); +} +.bubbleName { font-size: 13px !important; } +.bubbleAuthor, .bubbleRemixFrom, .bubbleRemixCount { font-size: 10px !important; margin-top: 2px !important; } +.bubbleVotes { margin-top: 6px !important; grid-template-columns: 1fr auto 1fr !important; justify-content: center; } +.bubbleActions { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + gap: 5px !important; + margin-top: 6px !important; +} +.bubbleActions button { min-height: 32px; font-size: 10px !important; } +.bubbleRemixPrimary { + background: linear-gradient(180deg, #ffb3d1, #ff7daf) !important; + border-width: 3px !important; +} +.bubbleMenuButton { + margin-top: 6px; + min-width: 64px; + min-height: 28px; + padding: 5px 8px !important; + border-radius: 12px !important; + background: #fff2d7 !important; + font-size: 10px !important; +} +.bubbleMenuActions { + display: flex; + justify-content: center; + gap: 6px; + padding-top: 4px; + margin-top: 4px; + border-top: 1px dashed rgba(36,48,68,.25); +} +.bubbleMenuActions[hidden] { display: none !important; } +.bubbleMenuActions button { font-size: 10px !important; padding: 5px 8px !important; } +@media (max-width: 760px) { + .drawDockButton { min-width: 176px; min-height: 56px; font-size: 20px !important; bottom: 16px; } + .islandClickHint { display: none; } +} + + +/* v15 analog day-night clock */ +.analogClock { + --clock-rotate: 0deg; + position: fixed; + top: clamp(104px, 14vh, 158px); + right: 18px; + z-index: 28; + width: 82px; + height: 100px; + display: grid; + place-items: center; + gap: 4px; + pointer-events: none; +} +.analogClockDial { + position: relative; + width: 72px; + height: 72px; + border: 4px solid var(--line); + border-radius: 50%; + background: + radial-gradient(circle at 50% 50%, rgba(255,255,245,.42) 0 6%, rgba(255,255,245,0) 7%), + conic-gradient(from -90deg, + #142147 0deg 54deg, + #5468aa 70deg, + #f5b067 96deg, + #8bd8ff 128deg 232deg, + #ff9d4f 266deg, + #c84f2e 292deg, + #1b2a58 326deg 360deg); + box-shadow: 0 5px 0 rgba(36,48,68,.20), 0 8px 18px rgba(18,28,44,.18); + image-rendering: pixelated; +} +.analogClockDial::after { + content: ''; + position: absolute; + inset: 8px; + border: 1px dashed rgba(36,48,68,.25); + border-radius: 50%; +} +.analogClockHand { + position: absolute; + left: 50%; + top: 50%; + width: 4px; + height: 28px; + transform-origin: 50% 100%; + transform: translate(-50%, -100%) rotate(var(--clock-rotate)); + background: #243044; + border-radius: 6px 6px 2px 2px; + box-shadow: 1px 0 0 rgba(255,255,255,.45); +} +.analogClockHand::after { + content: ''; + position: absolute; + left: 50%; + bottom: -5px; + width: 10px; + height: 10px; + transform: translateX(-50%); + border: 3px solid var(--line); + background: #fff7e8; + border-radius: 50%; +} +.analogClockLabel { + min-width: 70px; + padding: 2px 7px; + border: 2px solid var(--line); + border-radius: 999px; + background: rgba(255,248,232,.9); + box-shadow: 0 3px 0 rgba(36,48,68,.16); + text-align: center; + font-size: 10px; + font-weight: 950; +} +.analogPhaseBar { display: none !important; } +@media (max-width: 760px) { + .analogClock { top: 82px; right: 10px; transform: scale(.86); transform-origin: top right; } +} From 94571943cf4f70cbabad1488d06559df80b166f8 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 17:19:36 +0900 Subject: [PATCH 2/6] split --- .gitignore | 3 + README.md | 12 + README.md.bak | 58 - app.js | 428 +- app.js.bak | 3774 ----------------- index.html | 44 +- index.html.bak | 203 - js/core-utils.js | 33 + js/lighting.js | 136 +- js/module-loader.js | 22 + js/phase2-sync.js | 4 + js/phase2-sync.js.bak | 402 -- js/render-pipeline.js | 11 - js/rotation-policy.js | 76 + js/state-index.js | 103 + js/vector.js | 24 - .../compression_lab.cpython-313.pyc | Bin 2515 -> 0 bytes .../rotation_worker.cpython-313.pyc | Bin 20663 -> 0 bytes server/rotation_policy.json | 12 + server/rotation_worker.py | 36 +- server/test_rotation_worker.py | 64 + styles.css | 34 + styles.css.bak | 901 ---- 23 files changed, 817 insertions(+), 5563 deletions(-) create mode 100644 .gitignore delete mode 100644 README.md.bak delete mode 100644 app.js.bak delete mode 100644 index.html.bak create mode 100644 js/core-utils.js create mode 100644 js/module-loader.js delete mode 100644 js/phase2-sync.js.bak delete mode 100644 js/render-pipeline.js create mode 100644 js/rotation-policy.js create mode 100644 js/state-index.js delete mode 100644 js/vector.js delete mode 100644 server/__pycache__/compression_lab.cpython-313.pyc delete mode 100644 server/__pycache__/rotation_worker.cpython-313.pyc create mode 100644 server/rotation_policy.json create mode 100644 server/test_rotation_worker.py delete mode 100644 styles.css.bak diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d52710d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.bak +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 7336ed8..209f736 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ Browser-only prototype for a shared pixel-art island. Open `index.html` in a modern browser. No build step or third-party dependency is required. +## Refactor layout + +- `app.js` remains the browser bootstrap and main UI coordinator. +- `js/core-utils.js`, `js/state-index.js`, `js/rotation-policy.js`, and `js/lighting.js` hold reusable logic shared by the app. +- `js/editor-actions.js` is lazy-loaded when Pixel Studio opens; the app keeps inline fallbacks for first-use safety. +- Server rotation defaults can be supplied with `server/rotation_policy.json` and `--config`. +- Server policy tests live in `server/test_rotation_worker.py`. + ## Current prototype scope - Draw pixel assets in the in-browser editor. @@ -269,3 +277,7 @@ Use this to compare minified JSON, gzip, zlib, and Brotli when available. Produc - Replaced the numeric clock with a fixed analog day-night clock using a sky-color conic gradient and no numbers. - Kept selected-object shadows mirrored from the ground line while the sprite jumps. - Smoothed sky and overlay interpolation across dawn, day, evening, and night. + +## v16b hotfix +- Fixed the dark/blank world regression caused by using `source-in` compositing directly on the main world canvas for ship water reflection. +- Ship reflections are now precomposited on an offscreen canvas before drawing, so terrain and sprites are not erased or darkened. diff --git a/README.md.bak b/README.md.bak deleted file mode 100644 index 592176b..0000000 --- a/README.md.bak +++ /dev/null @@ -1,58 +0,0 @@ -# Pixel Island Summoner - -Browser-only prototype for a shared pixel-art island. - -## Run - -Open `index.html` in a modern browser. No build step or third-party dependency is required. - -## Current prototype scope - -- Draw pixel assets in the in-browser editor. -- Save assets to the local library. -- Place static assets and summon dynamic assets onto the island. -- Dynamic assets default to right-facing on spawn; left-facing is rendered by mirroring during movement only. -- Local voting, hiding, remix/copy edit, and import/export tools are included. - -## Phase 1 changes - -- Asset data is normalized to schema 2+ compatible shape. -- Dynamic left-facing pixels are not stored; `faces.left` is represented as `mirror`. -- Empty depth/light metadata is omitted or normalized. -- Editor tools include Draw, Erase, Fill, Pick, Undo, and Redo. - -## Phase 2 changes - -- Local save uses a compact Phase 2 envelope in `localStorage`. -- Pixel planes are bbox-cropped and RLE-compressed when this is smaller than raw cropped data. -- Compact asset bundles can be exported separately from world snapshots. -- World snapshots contain placements, dynamic summons, and an asset manifest rather than full asset blobs. -- Local object/asset mutations append small sync events to `eventLog`. -- IndexedDB asset/snapshot cache helpers are included for future server-backed missing-asset fetches. - -## Phase 3 changes - -- Terrain rendering is chunk-cached instead of using one large terrain canvas. -- Only terrain chunks intersecting the current viewport are drawn. -- Static and dynamic objects outside the viewport margin are culled before sprite generation/sorting. -- Dynamic runtime updates are stepped at 15 Hz and capped per frame to avoid spiral-of-death on slow devices. -- Rendering pauses while the browser tab is hidden and resumes on visibility change. -- Tile/object lookups use a small spatial index for inspect, erase, and nearby-target searches. -- Data tab reports visible terrain chunks for quick performance inspection. - -## Phase 4 changes - -- Editor schema is now 5. -- Added Line and Rect tools. Rect supports filled rectangles with Shift; right-click erases line/rect cells. -- Added Select tool. Drag to select a rectangular area, then drag the selection or use arrow keys/buttons to move it. -- Nudge buttons move the active selection; without a selection they shift the whole drawing. -- Added horizontal/vertical flip, palette-colored outline generation, and clear selection. -- Added editor PNG export/import. PNG import quantizes the image to the current island palette and current canvas size. -- Added keyboard shortcuts: B/E/F/I/L/R/S for tools, Ctrl/Cmd+Z/Y for history, arrows for moving selected pixels, Escape to clear selection. - -## Export formats - -- **Export full JSON**: human-readable full local state. -- **Export compact**: compact local state suitable for saving/transferring. -- **Export snapshot**: placement state plus asset manifest; requires asset bundles or cache to render fully. -- **Export asset bundle**: compact asset blob list for missing-asset transfer. diff --git a/app.js b/app.js index 82ebed8..aa0b127 100644 --- a/app.js +++ b/app.js @@ -20,16 +20,22 @@ const VIEW_CULL_MARGIN = 192; const DYNAMIC_LOGIC_STEP_MS = 1000 / 15; const MAX_DYNAMIC_STEPS_PER_FRAME = 3; + const MAX_SPRITE_CACHE_ENTRIES = 260; const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; const PALETTE = buildPalette(); const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color])); const MODULES = window.PixelIslandModules || {}; - const EditorActions = MODULES.EditorActions || null; const Phase2Sync = MODULES.Phase2Sync || null; + const StateIndex = MODULES.StateIndex || null; + const RotationPolicy = MODULES.RotationPolicy || null; + const Lighting = MODULES.Lighting || null; + const ModuleLoader = MODULES.ModuleLoader || null; const EDITOR_HISTORY_LIMIT = 80; const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 }; + const editorActions = () => MODULES.EditorActions || null; + const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5); @@ -143,6 +149,7 @@ let world = makeWorld(); let terrainCache = buildTerrainCache(world); + let terrainFrontCache = buildTerrainFrontCache(world); let state = loadState(); let selectedAssetId = state.assets[0]?.id ?? null; let mode = 'inspect'; @@ -154,6 +161,7 @@ let hoverTile = null; let dynamicRuntime = []; let spriteCache = new Map(); + let lastRenderedSpriteInfo = new Map(); let lastRuntimeUpdate = performance.now(); let lastClockSecond = -1; let toastTimer = null; @@ -186,6 +194,7 @@ let coastalFoamTextures = null; let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; let shadowCanvasCache = new WeakMap(); + let shipReflectionCanvasCache = new WeakMap(); let animationFrameId = 0; let dynamicLogicRemainder = 0; let worldIndex = makeEmptyWorldIndex(); @@ -210,6 +219,7 @@ function bootstrap() { resizeCanvas(); resetView(false); + rebuildWorldIndex(); hydrateRuntime(); coastalFoamTextures = buildCoastalFoamTextures(); wireUI(); @@ -221,7 +231,6 @@ clearEditorHistory(); renderLibrary(); updateSelectedLabel(); - rebuildWorldIndex(); updateSyncStats(); updateGuardrailStats(); hydrateVisualSettingsUI(); @@ -478,12 +487,19 @@ function setDrawerOpen(open) { els.drawer.classList.toggle('open', open); els.openEditor.hidden = open; + if (open) ensureEditorHelpers(); if (!open) { selectedObject = null; updateSelectionBubble(performance.now()); } } + function ensureEditorHelpers() { + if (editorActions() || !ModuleLoader?.loadScript) return; + ModuleLoader.loadScript('./js/editor-actions.js') + .catch((error) => console.warn('Editor helper lazy load failed; using inline fallbacks.', error)); + } + function onDocumentContextMenu(event) { if (!els.drawer?.classList.contains('open')) return; if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return; @@ -754,7 +770,7 @@ } function makeEmptyWorldIndex() { - return { placedByTile: new Map(), dynamicByHomeTile: new Map() }; + return StateIndex?.build ? StateIndex.build({ assets: [], placed: [], dynamicSummons: [] }) : { assetById: new Map(), staticById: new Map(), dynamicById: new Map(), objectsById: new Map(), objectIdsByAssetId: new Map(), placedByTile: new Map(), dynamicByHomeTile: new Map() }; } function pushIndexBucket(map, key, item) { @@ -764,12 +780,25 @@ } function rebuildWorldIndex() { + if (StateIndex?.build) { + worldIndex = StateIndex.build(state); + return; + } const next = makeEmptyWorldIndex(); + for (const asset of state.assets || []) { + if (asset?.id) next.assetById.set(asset.id, asset); + } for (const placed of state.placed || []) { + next.staticById.set(placed.id, placed); + next.objectsById.set(placed.id, { kind: 'static', object: placed }); pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed); + pushIndexBucket(next.objectIdsByAssetId, placed.assetId, placed.id); } for (const summon of state.dynamicSummons || []) { + next.dynamicById.set(summon.id, summon); + next.objectsById.set(summon.id, { kind: 'dynamic', object: summon }); pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon); + pushIndexBucket(next.objectIdsByAssetId, summon.assetId, summon.id); } worldIndex = next; } @@ -789,12 +818,12 @@ } function getObjectRecord(kind, objectId) { - if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null; - return state.dynamicSummons.find((p) => p.id === objectId) || null; + if (kind === 'static') return worldIndex.staticById?.get(objectId) || state.placed.find((p) => p.id === objectId) || null; + return worldIndex.dynamicById?.get(objectId) || state.dynamicSummons.find((p) => p.id === objectId) || null; } function getObjectPublicAt(kind, object) { - return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now()); + return RotationPolicy?.objectPublicAt ? RotationPolicy.objectPublicAt(object) : Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now()); } function isPermanentlyHiddenObject(object) { @@ -804,8 +833,12 @@ function isModeratedAssetHidden(asset) { if (!asset) return false; if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true; - const allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])]; - return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation'); + const objectIds = worldIndex.objectIdsByAssetId?.get(asset.id) || []; + for (const objectId of objectIds) { + const entry = worldIndex.objectsById?.get(objectId); + if (entry?.object && isPermanentlyHiddenObject(entry.object) && entry.object.hiddenReason === 'moderation_violation') return true; + } + return false; } function isServerSuppressedObject(object) { @@ -813,25 +846,12 @@ } function getObjectRotationEntry(kind, object, baseIndex = 0) { - const objectId = object?.id; - const votes = getObjectVoteCounts(objectId); + const votes = getObjectVoteCounts(object?.id); + if (RotationPolicy?.entry) return RotationPolicy.entry(kind, object, baseIndex, votes, PHASE5_GUARDRAILS); const rawUp = Number(votes.up) || 0; const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap); const down = Number(votes.down) || 0; - const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots; - const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots; - return { - kind, - object, - id: objectId, - assetId: object?.assetId, - publicAt: getObjectPublicAt(kind, object), - baseIndex, - up, - rawUp, - down, - effectiveSlot: baseIndex + delay - advance - }; + return { kind, object, id: object?.id, assetId: object?.assetId, publicAt: getObjectPublicAt(kind, object), baseIndex, up, rawUp, down, effectiveSlot: baseIndex + up * PHASE5_GUARDRAILS.upvoteDelaySlots - down * PHASE5_GUARDRAILS.downvoteAdvanceSlots }; } function getRotationEntries(includeLocalHidden = false) { @@ -853,6 +873,7 @@ } function seededScore(id, salt = '') { + if (RotationPolicy?.seededScore) return RotationPolicy.seededScore(id, salt, state.lastRotationAt || Date.now()); const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`); return parseInt(hash.slice(0, 8), 16) / 0xffffffff; } @@ -860,6 +881,7 @@ function getIslandDisplayBuckets() { const entries = getRotationEntries(false); const localLimit = getLocalDisplayLimit(); + if (RotationPolicy?.buckets) return RotationPolicy.buckets(entries, localLimit, PHASE5_GUARDRAILS, state.lastRotationAt || Date.now()); const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit); const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap)); const newest = entries @@ -904,6 +926,7 @@ function currentPublishLimit(now = Date.now()) { if (!state.account?.createdAt) return 0; + if (RotationPolicy?.publishLimit) return RotationPolicy.publishLimit(state.account, now, PHASE5_GUARDRAILS); return getAccountAgeMs(now) < 24 * 60 * 60 * 1000 ? PHASE5_GUARDRAILS.publishLimitFirstDay : PHASE5_GUARDRAILS.publishLimitTrusted; @@ -1473,8 +1496,9 @@ if (paintTool === 'fill') { const displayPixels = getDisplayEditorPixels(); const fillColor = shiftKey ? null : selectedColorCode; - const nextDisplay = EditorActions?.floodFill - ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor) + const actions = editorActions(); + const nextDisplay = actions?.floodFill + ? actions.floodFill(displayPixels, editorSize, x, y, fillColor) : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); if (nextDisplay.changed) { editorPixels = nextDisplay.pixels; @@ -2560,6 +2584,7 @@ editParentId = null; editOriginalId = null; editingAssetId = null; + rebuildWorldIndex(); saveState(); spriteCache.clear(); hydrateRuntime(); @@ -2818,7 +2843,8 @@ function makeAssetCard(asset) { const card = document.createElement('article'); - card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`; + const expanded = asset.id === selectedAssetId; + card.className = `assetCard${expanded ? ' selected expanded' : ''}`; const preview = document.createElement('canvas'); preview.className = 'assetPreview'; @@ -2828,53 +2854,60 @@ const meta = document.createElement('div'); meta.className = 'assetMeta'; - const lineage = asset.parentAssetId ? ' / derivative' : ''; - meta.innerHTML = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`; - meta.querySelector('strong').textContent = asset.name; - meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`; + const title = document.createElement('strong'); + title.textContent = asset.name; + meta.append(title); card.addEventListener('click', () => { - selectedAssetId = asset.id; + selectedAssetId = expanded ? null : asset.id; updateSelectedLabel(); renderLibrary(); - focusAssetInWorld(asset); + if (selectedAssetId) focusAssetInWorld(asset); }); - const actions = document.createElement('div'); - actions.className = 'assetActions'; - const assetVotes = getAssetVoteCounts(asset.id); - const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0; - const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); - const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); }); - up.classList.toggle('activeVote', assetPreviousVote > 0); - down.classList.toggle('activeVote', assetPreviousVote < 0); - up.classList.toggle('mutedVote', assetPreviousVote < 0); - down.classList.toggle('mutedVote', assetPreviousVote > 0); - const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); - hide.classList.toggle('mutedAction', assetPreviousVote >= 0); - const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); - const remix = makeButton('Remix', (event) => { - event?.stopPropagation?.(); - remixEdit(asset); - }); - const move = isMine ? makeButton('Place/Move', (event) => { - event?.stopPropagation?.(); - selectedAssetId = asset.id; - updateSelectedLabel(); - renderLibrary(); - setMode('place'); - setDrawerOpen(false); - toast('Click a valid tile to place or move it.'); - }) : null; - const del = makeButton('Delete', (event) => { - event?.stopPropagation?.(); - deleteAsset(asset); - }); - del.classList.add('danger'); - actions.append(up, down, hide, remix); - if (move) actions.append(move); - if (isMine) actions.append(del); - meta.append(actions); + if (expanded) { + const lineage = asset.parentAssetId ? ' / derivative' : ''; + const span1 = document.createElement('span'); + span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`; + const span2 = document.createElement('span'); + span2.textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`; + meta.append(span1, span2); + + const actions = document.createElement('div'); + actions.className = 'assetActions'; + const assetVotes = getAssetVoteCounts(asset.id); + const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0; + const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); + const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); }); + up.classList.toggle('activeVote', assetPreviousVote > 0); + down.classList.toggle('activeVote', assetPreviousVote < 0); + up.classList.toggle('mutedVote', assetPreviousVote < 0); + down.classList.toggle('mutedVote', assetPreviousVote > 0); + const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); + hide.classList.toggle('mutedAction', assetPreviousVote >= 0); + const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); + const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); }); + const move = isMine ? makeButton('Place/Move', (event) => { + event?.stopPropagation?.(); + selectedAssetId = asset.id; + updateSelectedLabel(); + renderLibrary(); + setMode('place'); + setDrawerOpen(false); + toast('Click a valid tile to place or move it.'); + }) : null; + const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); + del.classList.add('danger'); + actions.append(up, down, hide, remix); + 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}`; + meta.append(mini); + } + card.append(preview, meta); return card; } @@ -3381,6 +3414,7 @@ } function getShadowForMinute(minute) { + if (Lighting?.getShadowForMinute) return Lighting.getShadowForMinute(minute); const m = clamp(Number(minute) || 0, 0, 10); const smooth = (t) => clamp(t, 0, 1) * clamp(t, 0, 1) * (3 - 2 * clamp(t, 0, 1)); let dirX = -0.9; @@ -3449,6 +3483,7 @@ } function getPhase() { + if (Lighting?.getPhase) return Lighting.getPhase({ dayMs: DAY_MS, now: Date.now(), dayNightEnabled: visualSettings().enableDayNight !== false, mixHex }); if (visualSettings().enableDayNight === false) { return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; } @@ -3544,6 +3579,7 @@ ctx.imageSmoothingEnabled = false; drawTerrainCache(terrainCache); drawHoverTile(); + lastRenderedSpriteInfo.clear(); const lightSources = []; const visibleItems = drawObjects(time, lightSources, phase); if (visualSettings().enableParticles) { @@ -3554,6 +3590,7 @@ drawSpawnEffects(time); drawConfettiParticles(time); } + drawTerrainFrontCache(terrainFrontCache); const nightLightsActive = areNightLightsActive(phase); ctx.restore(); @@ -3632,10 +3669,23 @@ const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird'); if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y); let bob = 0; + let stretchX = 1; + let stretchY = 1; let alpha = 1; let side = 'right'; let angle = 0; - if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6; + + if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) { + bob += Math.sin(time / 900 + item.x * .7) * 1.6; + if (asset.subtype === 'ship') { + const drift = Math.sin(time / 2400 + item.x * 0.37 + item.y * 0.19) * 5.2; + const glide = Math.cos(time / 3200 + item.x * 0.21) * 2.6; + pos.x += drift; + pos.y += glide * 0.22; + angle += Math.sin(time / 1400 + item.x * 0.2) * 0.035; + } + } + if (asset.category === 'dynamic') { const runtime = item.source || {}; const seed = Number.isFinite(runtime.seed) ? runtime.seed : 0; @@ -3653,32 +3703,41 @@ bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); const rand = pseudoNoise(cycle + seed * 13.17); - angle = (rand - 0.5) * 0.24 * hop; + angle += (rand - 0.5) * 0.24 * hop; + const land = Math.pow(Math.max(0, Math.cos(jumpPhase * 2)), 10) * Math.max(0, 1 - hop); + stretchX += land * 0.18; + stretchY -= land * 0.12; } } if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + seed) * 4.5; if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + seed) * 1.7; } } + if (includeSelectBounce && selectedObject?.id === item.source?.id) { const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 560, 0, 1); if (selectedT < 1) { const bounce = Math.sin(selectedT * Math.PI); - bob += -bounce * 8; + bob += -bounce * (asset.category === 'dynamic' ? 9 : 8); const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1; - const tiltSign = pseudoNoise(tiltSeed) > 0.5 ? 1 : -1; - angle += tiltSign * bounce * 0.18; + const 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; } } + const sprite = getSpriteCanvas(asset, side); const drawX = pos.x - sprite.width / 2; const anchorY = asset.category === 'static' ? 0 : TILE_H / 2; const drawY = pos.y + anchorY - sprite.height + bob; - return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY }; + return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY, stretchX, stretchY }; } function drawSpriteItem(item, time, lightSources, underwater, phase) { const info = getSpriteDrawInfo(item, time, true); - const { asset, pos, sprite, drawX, drawY, alpha, angle } = info; + 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); @@ -3686,16 +3745,19 @@ ctx.save(); ctx.globalAlpha = alpha; - if (angle) { - ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8)); - ctx.rotate(angle); + const pivotX = Math.round(drawX + sprite.width / 2); + const pivotY = Math.round(drawY + sprite.height * 0.8); + if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) { + ctx.translate(pivotX, pivotY); + if (angle) ctx.rotate(angle); + if (Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.scale(stretchX, stretchY); ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); } else { ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY)); } if (underwater) { ctx.fillStyle = 'rgba(103, 181, 217, .18)'; - if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height); + if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height); else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height); } ctx.restore(); @@ -3920,22 +3982,30 @@ function drawSpriteShadow(info, phase) { - const { drawX, sprite, pos, anchorY, bob } = info; + const { drawX, sprite, pos, anchorY, bob, 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 silhouette = getShadowCanvas(sprite); const contactX = drawX + sprite.width / 2; - // Keep the mirror line on the ground. When a selected sprite jumps upward, - // the reflected silhouette moves downward by the same amount instead of floating. const groundY = pos.y + anchorY; const mirrorOffset = Math.max(0, -(bob || 0)); ctx.save(); ctx.globalAlpha = shadow.alpha; ctx.translate(contactX, groundY); - ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); + 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(); + + if (asset?.subtype === 'ship') { + const reflection = getShipReflectionCanvas(sprite); + ctx.save(); + ctx.globalAlpha = 0.24; + ctx.translate(contactX, groundY + 1); + 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.restore(); + } } function getShadowCanvas(sprite) { @@ -3957,6 +4027,29 @@ return canvas; } + function getShipReflectionCanvas(sprite) { + if (shipReflectionCanvasCache.has(sprite)) return shipReflectionCanvasCache.get(sprite); + const canvas = document.createElement('canvas'); + canvas.width = sprite.width; + canvas.height = sprite.height; + const c = canvas.getContext('2d'); + c.imageSmoothingEnabled = false; + c.save(); + c.translate(0, sprite.height); + c.scale(1, -1); + c.drawImage(sprite, 0, 0); + c.restore(); + c.globalCompositeOperation = 'source-in'; + const refl = c.createLinearGradient(0, 0, 0, canvas.height); + refl.addColorStop(0, 'rgba(174,230,255,0.46)'); + refl.addColorStop(0.55, 'rgba(92,172,214,0.20)'); + refl.addColorStop(1, 'rgba(54,120,170,0.02)'); + c.fillStyle = refl; + c.fillRect(0, 0, canvas.width, canvas.height); + shipReflectionCanvasCache.set(sprite, canvas); + return canvas; + } + function currentVoterKey() { return String(state.account?.id || state.authorName || els.authorName?.value || 'Local Artist').trim().toLowerCase(); @@ -4080,16 +4173,17 @@ } 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(14, Math.min(30, info.sprite.height * 0.18)); - return { - x: info.drawX + info.sprite.width / 2, - y: info.drawY - topPad, - asset: item.asset, - objectId: item.source?.id || selectedObject.id - }; + 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 }; } function updateSelectionBubble(time) { @@ -4110,8 +4204,8 @@ return; } els.selectionBubble.hidden = false; - const bubbleX = clamp(sx, 96, cw - 96); - const bubbleY = clamp(sy, 28, ch - 118); + 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%)'; @@ -4383,8 +4477,14 @@ 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 key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; - if (spriteCache.has(key)) return spriteCache.get(key); + 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}`; + if (spriteCache.has(key)) { + const cached = spriteCache.get(key); + spriteCache.delete(key); + spriteCache.set(key, cached); + 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; @@ -4404,10 +4504,18 @@ c.fillRect(x * scale, y * scale, scale, scale); } } - spriteCache.set(key, canvas); + rememberSpriteCanvas(key, canvas); return canvas; } + function rememberSpriteCanvas(key, canvas) { + spriteCache.set(key, canvas); + while (spriteCache.size > MAX_SPRITE_CACHE_ENTRIES) { + const oldest = spriteCache.keys().next().value; + spriteCache.delete(oldest); + } + } + 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); @@ -4534,6 +4642,49 @@ } } + 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) }; @@ -4591,6 +4742,35 @@ } + 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 = { @@ -4603,33 +4783,6 @@ const fill = tile.shade > 0 ? base : alt; const lift = getTileLift(tile); - if (tile.type === 'highland') { - 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(); - } - } - c.fillStyle = fill; c.beginPath(); c.moveTo(x, y - lift); @@ -4710,6 +4863,7 @@ function saveState() { state.schema = SAVE_SCHEMA; + canonicalizeAssetStorage(); state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) }; state.moderationReports = normalizeModerationReports(state.moderationReports || []); state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; @@ -4743,6 +4897,18 @@ }; } + function canonicalizeAssetStorage() { + let changed = false; + state.assets = (state.assets || []).map((asset) => { + const right = asset?.faces?.right || asset?.pixels; + const needsCanonical = Array.isArray(right) || Array.isArray(asset?.pixels) || Array.isArray(asset?.meta?.depthPixels); + if (!needsCanonical) return asset; + changed = true; + return normalizeAsset(asset); + }); + if (changed) rebuildWorldIndex(); + } + function normalizeAsset(asset) { const size = Number(asset.size) || 16; const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; @@ -4998,6 +5164,7 @@ const byId = new Map(state.assets.map((asset) => [asset.id, asset])); importedAssets.forEach((asset) => byId.set(asset.id, asset)); state.assets = [...byId.values()]; + rebuildWorldIndex(); saveState(); spriteCache.clear(); renderLibrary(); @@ -5046,6 +5213,20 @@ state.sync.lastEventId = event.id; } + function applySyncEvent(event) { + if (!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 }); + else Phase2Sync?.applyEvent?.(state, event); + rebuildWorldIndex(); + spriteCache.clear(); + state.sync ||= { lastEventId: null }; + state.sync.lastEventId = event.id || state.sync.lastEventId; + return true; + } + function cachePhase2State() { if (!Phase2Sync?.cacheAssets) return; Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); @@ -5211,7 +5392,7 @@ } function findAsset(id) { - return state.assets.find((asset) => asset.id === id) || null; + return worldIndex.assetById?.get(id) || state.assets.find((asset) => asset.id === id) || null; } function blankPixels(size) { @@ -5727,5 +5908,6 @@ } + window.PixelIslandDebug = { ...(window.PixelIslandDebug || {}), applySyncEvent }; bootstrap(); })(); diff --git a/app.js.bak b/app.js.bak deleted file mode 100644 index f1b742c..0000000 --- a/app.js.bak +++ /dev/null @@ -1,3774 +0,0 @@ -(() => { - 'use strict'; - - console.info('Pixel Island Summoner loaded'); - - const STORAGE_KEY = 'pixel-island-summoner:reset'; - const SAVE_SCHEMA = 5; - const WORLD_W = 144; - const WORLD_H = 112; - const TILE_W = 32; - const TILE_H = 16; - const ORIGIN_X = WORLD_H * TILE_W / 2 + 80; - const ORIGIN_Y = 42; - const DAY_MS = 10 * 60 * 1000; - const STATIC_SCALE = 2; - const DYNAMIC_SCALE = 1; - const MAX_ZOOM = 3.2; - const MIN_ZOOM = 0.45; - const TERRAIN_CHUNK_SIZE = 16; - const VIEW_CULL_MARGIN = 192; - const DYNAMIC_LOGIC_STEP_MS = 1000 / 15; - const MAX_DYNAMIC_STEPS_PER_FRAME = 3; - - const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const PALETTE = buildPalette(); - const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color])); - const MODULES = window.PixelIslandModules || {}; - const EditorActions = MODULES.EditorActions || null; - const Phase2Sync = MODULES.Phase2Sync || null; - const EDITOR_HISTORY_LIMIT = 80; - - const $ = (id) => document.getElementById(id); - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5); - const mod = (n, m) => ((n % m) + m) % m; - const lerp = (a, b, t) => a + (b - a) * t; - - - function toggleHidden(el, hidden) { - if (el) el.hidden = hidden; - } - - function currentRole() { - return els.assetCategory?.value || 'nature'; - } - - function roleToCategory(role) { - return role === 'human' || role === 'animal' ? 'dynamic' : 'static'; - } - - function roleToSubtype(role) { - return role || 'other'; - } - - function subtypeToRole(asset) { - const subtype = asset?.subtype; - if (['human', 'animal', 'nature', 'building', 'other'].includes(subtype)) return subtype; - if (subtype === 'water') return 'other'; - if (asset?.category === 'dynamic') return 'animal'; - return 'other'; - } - - const els = { - canvas: $('worldCanvas'), - openEditor: $('openEditor'), - closeEditor: $('closeEditor'), - drawer: $('studioDrawer'), - tabs: [...document.querySelectorAll('.tab')], - panels: [...document.querySelectorAll('.tabPanel')], - phaseLabel: $('phaseLabel'), - phaseBar: $('phaseBar'), - authorName: $('authorName'), - selectedAssetName: $('selectedAssetName'), - tileInfo: $('tileInfo'), - toast: $('toast'), - selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleHide: $('bubbleHide'), - - modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'), - drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), - zoomOut: $('zoomOut'), zoomIn: $('zoomIn'), resetView: $('resetView'), - drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'), - - assetName: $('assetName'), - assetSize: $('assetSize'), - assetCategory: $('assetCategory'), - staticKindWrap: $('staticKindWrap'), - dynamicKindWrap: $('dynamicKindWrap'), - roleHint: $('roleHint'), - sideSwitcher: $('sideSwitcher'), - editRight: $('editRight'), editLeft: $('editLeft'), - paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), - toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), - paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), - lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), - settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), - lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), - exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'), - exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats') - }; - - const ctx = els.canvas.getContext('2d', { alpha: false }); - const pctx = els.paintCanvas.getContext('2d', { alpha: true }); - ctx.imageSmoothingEnabled = false; - pctx.imageSmoothingEnabled = false; - - let dpr = window.devicePixelRatio || 1; - let cw = 1; - let ch = 1; - - let world = makeWorld(); - let terrainCache = buildTerrainCache(world); - let state = loadState(); - let selectedAssetId = state.assets[0]?.id ?? null; - let mode = 'inspect'; - let view = { x: 0, y: 0, zoom: 1 }; - let pointer = { - down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0, - dragging: false, downTime: 0 - }; - let hoverTile = null; - let dynamicRuntime = []; - let spriteCache = new Map(); - let lastRuntimeUpdate = performance.now(); - let lastClockSecond = -1; - let toastTimer = null; - - let editorSize = 8; - let editorPixels = blankPixels(8); - let editorLeftPixels = blankPixels(8); - let editingSide = 'right'; - let paintTool = 'brush'; - let selectedColorCode = 'a'; - let advancedDraw = false; - let depthPixels = blankPixels(8).map(() => 0); - let isPainting = false; - let staticKind = 'nature'; - let dynamicKind = 'human'; - let lightPixels = []; - let doorPixel = { x: 8, y: 15 }; - let editParentId = null; - let editOriginalId = null; - let editingAssetId = null; - let lastPaintedKey = ''; - let spawnEffects = []; - let bubbleParticles = []; - let confettiParticles = []; - let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; - let shadowCanvasCache = new WeakMap(); - let animationFrameId = 0; - let dynamicLogicRemainder = 0; - let worldIndex = makeEmptyWorldIndex(); - let selectedObject = null; - let libraryFilter = 'all'; - let renderPhase = null; - let editorView = { zoom: 1, x: 0, y: 0 }; - let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 }; - let editorHistory = []; - let editorFuture = []; - let editorGestureSnapshot = null; - let editorGestureChanged = false; - let suppressNextPaintClick = false; - let suppressMousePaintUntil = 0; - let editorSelection = null; - let selectionGesture = null; - let shapeGesture = null; - let shapePreview = null; - - function bootstrap() { - resizeCanvas(); - resetView(false); - hydrateRuntime(); - wireUI(); - renderPalette(); - hydrateAuthorUI(); - refreshCategoryUI(); - setupEditor(8, blankPixels(8), null); - clearEditorHistory(); - renderLibrary(); - updateSelectedLabel(); - rebuildWorldIndex(); - updateSyncStats(); - scheduleFrame(); - } - - function wireUI() { - window.addEventListener('resize', () => { - resizeCanvas(); - render(); - }); - - els.openEditor.addEventListener('click', () => setDrawerOpen(true)); - els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); - - els.tabs.forEach((button) => { - button.addEventListener('click', () => setTab(button.dataset.tab)); - }); - - els.authorName?.addEventListener('input', () => { - state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; - saveState(); - renderLibrary(); - }); - - [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect'))); - [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place'))); - [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase'))); - [els.zoomOut, els.drawerZoomOut].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom / 1.2))); - [els.zoomIn, els.drawerZoomIn].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom * 1.2))); - [els.resetView, els.drawerResetView].filter(Boolean).forEach((el) => el.addEventListener('click', () => resetView(true))); - - els.canvas.addEventListener('pointerdown', onWorldPointerDown); - els.canvas.addEventListener('pointermove', onWorldPointerMove); - els.canvas.addEventListener('pointerup', onWorldPointerUp); - els.canvas.addEventListener('pointercancel', onWorldPointerUp); - els.canvas.addEventListener('wheel', onWorldWheel, { passive: false }); - els.canvas.addEventListener('contextmenu', (event) => event.preventDefault()); - 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); - resetEditorView(); - return true; - }); - updateSettingsSummary(); - }); - - els.assetCategory.addEventListener('change', refreshCategoryUI); - document.querySelectorAll('[data-static-kind]').forEach((button) => { - button.addEventListener('click', () => { - staticKind = button.dataset.staticKind; - document.querySelectorAll('[data-static-kind]').forEach((b) => b.classList.toggle('active', b === button)); - refreshCategoryUI(); - }); - }); - document.querySelectorAll('[data-dynamic-kind]').forEach((button) => { - button.addEventListener('click', () => { - dynamicKind = button.dataset.dynamicKind; - document.querySelectorAll('[data-dynamic-kind]').forEach((b) => b.classList.toggle('active', b === button)); - refreshCategoryUI(); - }); - }); - - els.editRight.addEventListener('click', () => setEditingSide('right')); - els.editLeft.addEventListener('click', () => setEditingSide('left')); - - els.toolBrush.addEventListener('click', () => setPaintTool('brush')); - els.toolErase.addEventListener('click', () => setPaintTool('erase')); - els.toolFill?.addEventListener('click', () => setPaintTool('fill')); - els.toolPick?.addEventListener('click', () => setPaintTool('pick')); - els.toolLine?.addEventListener('click', () => setPaintTool('line')); - els.toolRect?.addEventListener('click', () => setPaintTool('rect')); - els.toolSelect?.addEventListener('click', () => setPaintTool('select')); - els.toolLight.addEventListener('click', () => setPaintTool('light')); - els.toolDoor.addEventListener('click', () => setPaintTool('door')); - els.toolDepth?.addEventListener('click', () => setPaintTool('depth')); - els.toggleAdvanced?.addEventListener('click', () => { - advancedDraw = !advancedDraw; - toggleHidden(els.toolLight, !advancedDraw); - toggleHidden(els.toolDepth, !advancedDraw); - toggleHidden(els.advancedHint, !advancedDraw); - els.toggleAdvanced.classList.toggle('active', advancedDraw); - if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush'); - drawEditor(); - }); - els.clearPaint.addEventListener('click', () => { - commitEditorMutation(() => { - editorPixels = blankPixels(editorSize); - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - depthPixels = blankPixels(editorSize).map(() => 0); - lightPixels = []; - editorSelection = null; - return true; - }); - }); - els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); - els.flipVertical?.addEventListener('click', flipEditorVertical); - els.outlinePaint?.addEventListener('click', applyEditorOutline); - els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); }); - els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0)); - els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0)); - els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1)); - els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1)); - els.exportPng?.addEventListener('click', exportEditorPng); - els.importPng?.addEventListener('click', () => els.pngImportInput?.click()); - els.pngImportInput?.addEventListener('change', importEditorPng); - els.undoPaint?.addEventListener('click', undoEditor); - els.redoPaint?.addEventListener('click', redoEditor); - window.addEventListener('keydown', onEditorKeyDown); - els.voteUp?.addEventListener('click', () => voteSelected(1)); - els.voteDown?.addEventListener('click', () => voteSelected(-1)); - els.bubbleRemix?.addEventListener('click', () => remixSelected()); - els.bubbleHide?.addEventListener('click', () => hideSelected()); - - els.paintCanvas.addEventListener('pointerdown', onPaintPointerDown); - els.paintCanvas.addEventListener('pointermove', onPaintPointerMove); - els.paintCanvas.addEventListener('pointerup', onPaintPointerUp); - els.paintCanvas.addEventListener('pointercancel', onPaintPointerUp); - els.paintCanvas.addEventListener('mousedown', onPaintMouseDown); - window.addEventListener('mousemove', onPaintMouseMove); - window.addEventListener('mouseup', onPaintMouseUp); - els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false }); - els.paintCanvas.addEventListener('click', onPaintClick); - window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; finishEditorGesture(); }); - els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault()); - - els.saveAsset.addEventListener('click', saveAssetFromEditor); - els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor); - els.newAsset.addEventListener('click', newAsset); - els.exportData.addEventListener('click', exportData); - els.importData.addEventListener('click', importData); - els.exportCompact?.addEventListener('click', exportCompactData); - els.exportSnapshot?.addEventListener('click', exportSnapshotData); - els.exportAssetBundle?.addEventListener('click', exportAssetBundleData); - els.resetAll.addEventListener('click', resetAll); - els.showHiddenAssets?.addEventListener('click', () => { - els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden; - renderHiddenAssets(); - }); - } - - function resizeCanvas() { - dpr = window.devicePixelRatio || 1; - cw = Math.max(1, window.innerWidth); - ch = Math.max(1, window.innerHeight); - els.canvas.width = Math.floor(cw * dpr); - els.canvas.height = Math.floor(ch * dpr); - els.canvas.style.width = `${cw}px`; - els.canvas.style.height = `${ch}px`; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - } - - function resetView(announce) { - const bounds = terrainCache.bounds; - view.zoom = Math.min(1.05, Math.max(MIN_ZOOM, Math.min(cw / bounds.w, ch / bounds.h) * 1.18)); - view.x = cw / 2 - (bounds.x + bounds.w / 2) * view.zoom; - view.y = ch / 2 - (bounds.y + bounds.h / 2) * view.zoom + 34; - if (announce) toast('View reset.'); - } - - function setDrawerOpen(open) { - els.drawer.classList.toggle('open', open); - els.openEditor.hidden = open; - if (!open) { - selectedObject = null; - updateSelectionBubble(performance.now()); - } - } - - function onDocumentContextMenu(event) { - if (!els.drawer?.classList.contains('open')) return; - if (els.drawer.contains(event.target) || els.openEditor.contains(event.target)) return; - event.preventDefault(); - setDrawerOpen(false); - } - - function setTab(name) { - els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name)); - els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`)); - } - - function setMode(nextMode) { - mode = nextMode; - const isInspect = mode === 'inspect'; - const isPlace = mode === 'place'; - const isErase = mode === 'erase'; - [els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.classList.toggle('active', isInspect)); - [els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.classList.toggle('active', isPlace)); - [els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.classList.toggle('active', isErase)); - els.canvas.classList.toggle('placeCursor', isPlace); - els.canvas.classList.toggle('eraseCursor', isErase); - } - - function refreshCategoryUI() { - const role = currentRole(); - const isStatic = roleToCategory(role) === 'static'; - if (isStatic) staticKind = roleToSubtype(role); - else dynamicKind = roleToSubtype(role); - - toggleHidden(els.staticKindWrap, true); - toggleHidden(els.dynamicKindWrap, true); - toggleHidden(els.sideSwitcher, isStatic); - toggleHidden(els.staticSettingsPanel, !isStatic); - toggleHidden(els.dynamicSettingsPanel, true); - toggleHidden(els.toolLight, !advancedDraw); - toggleHidden(els.toolDepth, !advancedDraw); - toggleHidden(els.toolDoor, role !== 'building'); - toggleHidden(els.doorMarkerHint, role !== 'building'); - - if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth')) setPaintTool('brush'); - if (!isStatic && paintTool === 'door') setPaintTool('brush'); - if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush'); - if (isStatic) editingSide = 'right'; - - updateRoleHint(); - updateSideButtons(); - clampEditorView(); - drawEditor(); - updateSettingsSummary(); - } - - function updateRoleHint() { - if (!els.roleHint) return; - const role = currentRole(); - const textMap = { - human: 'Humans need ▶ Right and ◀ Left sprites. They hop often and visit buildings.', - animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.', - nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.', - building: 'Buildings attract humans. Use Door to mark the entrance.', - other: 'Other objects are neutral scenery and render at 2× scale.' - }; - els.roleHint.textContent = textMap[role] || textMap.other; - } - - function updateSettingsSummary() { - if (!els.settingsSummary) return; - const role = currentRole(); - if (roleToCategory(role) === 'static') { - const lightCount = lightPixels.length; - const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light'; - const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; - els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`; - } else { - els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`; - } - } - - function setPaintTool(tool) { - paintTool = tool; - [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); - ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); - const hints = { - brush: 'Draw pixels with the selected palette color.', - erase: 'Erase pixels. Erasing also clears light markers on that cell.', - fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.', - pick: 'Pick a color from the canvas and return to Draw.', - line: 'Drag to draw a straight line. Right-click erases along the line.', - rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', - select: 'Drag a rectangle to select pixels, then drag or nudge the selection.', - light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', - door: 'Click one pixel to mark a building door. Humans will enter near this point.', - depth: 'Advanced: paint depth values onto pixels. Shift/right-click erases depth.' - }; - els.editHint.textContent = hints[tool] || hints.brush; - } - - function setEditingSide(side) { - if (roleToCategory(currentRole()) !== 'dynamic') return; - editingSide = side; - updateSideButtons(); - drawEditor(); - } - - function updateSideButtons() { - if (els.editRight) els.editRight.classList.toggle('active', editingSide === 'right'); - if (els.editLeft) els.editLeft.classList.toggle('active', editingSide === 'left'); - updateSettingsSummary(); - } - - function onWorldPointerDown(event) { - event.preventDefault(); - els.canvas.setPointerCapture(event.pointerId); - pointer = { - down: true, - id: event.pointerId, - startX: event.clientX, - startY: event.clientY, - lastX: event.clientX, - lastY: event.clientY, - dragging: false, - downTime: performance.now() - }; - els.canvas.classList.add('dragging'); - } - - function onWorldPointerMove(event) { - const pos = getCanvasPoint(event); - hoverTile = screenToTile(pos.x, pos.y); - updateTileInfo(); - - if (!pointer.down || pointer.id !== event.pointerId) return; - const dx = event.clientX - pointer.lastX; - const dy = event.clientY - pointer.lastY; - const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); - if (total > 3) pointer.dragging = true; - if (pointer.dragging) { - view.x += dx; - view.y += dy; - } - pointer.lastX = event.clientX; - pointer.lastY = event.clientY; - } - - function onWorldPointerUp(event) { - if (!pointer.down || pointer.id !== event.pointerId) return; - els.canvas.classList.remove('dragging'); - const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY); - const elapsed = performance.now() - pointer.downTime; - const wasClick = total < 6 && elapsed < 600; - const pos = getCanvasPoint(event); - const tile = screenToTile(pos.x, pos.y); - if (wasClick) { - const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now()); - if (mode === 'place' && tile) placeSelected(tile.x, tile.y); - else if (mode === 'erase') { - if (pickedObject) eraseObject(pickedObject.kind, pickedObject.id); - else if (tile) eraseAt(tile.x, tile.y); - } else if (pickedObject) { - selectWorldObject(pickedObject.kind, pickedObject.id, pickedObject.assetId, performance.now()); - const asset = findAsset(pickedObject.assetId); - toast(asset ? `Selected ${asset.name}.` : 'Selected object.'); - } else if (tile) inspectAt(tile.x, tile.y); - } - pointer.down = false; - pointer.id = null; - pointer.dragging = false; - } - - function onWorldWheel(event) { - event.preventDefault(); - const pos = getCanvasPoint(event); - const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13; - zoomAt(pos.x, pos.y, view.zoom * factor); - } - - function zoomAt(screenX, screenY, nextZoom) { - nextZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM); - const worldX = (screenX - view.x) / view.zoom; - const worldY = (screenY - view.y) / view.zoom; - view.zoom = nextZoom; - view.x = screenX - worldX * view.zoom; - view.y = screenY - worldY * view.zoom; - } - - function getCanvasPoint(event) { - const rect = els.canvas.getBoundingClientRect(); - return { x: event.clientX - rect.left, y: event.clientY - rect.top }; - } - - function screenToTile(screenX, screenY) { - const worldX = (screenX - view.x) / view.zoom; - const worldY = (screenY - view.y) / view.zoom; - const localX = worldX - ORIGIN_X; - const localY = worldY - ORIGIN_Y; - const a = localX / (TILE_W / 2); - const b = localY / (TILE_H / 2); - const baseX = Math.floor((a + b) / 2); - const baseY = Math.floor((b - a) / 2); - let best = null; - let bestScore = -Infinity; - for (let ty = baseY - 2; ty <= baseY + 2; ty++) { - for (let tx = baseX - 2; tx <= baseX + 2; tx++) { - if (tx < 0 || ty < 0 || tx >= WORLD_W || ty >= WORLD_H) continue; - const tile = world.get(tx, ty); - const center = tileToWorld(tx, ty); - const lift = getTileLift(tile); - const nx = Math.abs(worldX - center.x) / (TILE_W / 2); - const ny = Math.abs(worldY - (center.y + TILE_H / 2 - lift)) / (TILE_H / 2); - if (nx + ny <= 1) { - const score = lift * 100 + (tx + ty); - if (score > bestScore) { - bestScore = score; - best = { x: tx, y: ty, tile }; - } - } - } - } - return best; - } - - function tileToWorld(x, y) { - return { - x: (x - y) * TILE_W / 2 + ORIGIN_X, - y: (x + y) * TILE_H / 2 + ORIGIN_Y - }; - } - - function tileKey(x, y) { - return `${x},${y}`; - } - - function makeEmptyWorldIndex() { - return { placedByTile: new Map(), dynamicByHomeTile: new Map() }; - } - - function pushIndexBucket(map, key, item) { - const bucket = map.get(key); - if (bucket) bucket.push(item); - else map.set(key, [item]); - } - - function rebuildWorldIndex() { - const next = makeEmptyWorldIndex(); - for (const placed of state.placed || []) { - pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed); - } - for (const summon of state.dynamicSummons || []) { - pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon); - } - worldIndex = next; - } - - function getPlacedAtTile(x, y) { - return worldIndex.placedByTile.get(tileKey(x, y)) || []; - } - - function getDynamicHomesAtTile(x, y) { - return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || []; - } - - - function getDrawableItems(time = performance.now(), viewport = null) { - const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); - const items = []; - for (const placed of state.placed) { - const asset = findAsset(placed.assetId); - if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue; - const itemX = placed.x + .5; - const itemY = placed.y + .5; - if (!isApproxVisible(asset, itemX, itemY, rect)) continue; - items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed }); - } - for (const runtime of dynamicRuntime) { - const asset = findAsset(runtime.assetId); - if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue; - if (time < runtime.hiddenUntil) continue; - if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; - items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); - } - items.sort(drawOrderCompare); - return items; - } - - function drawOrderCompare(a, b) { - return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || '')); - } - - function getViewportWorldRect(margin = 0) { - const invZoom = 1 / Math.max(0.0001, view.zoom); - return { - left: -view.x * invZoom - margin, - top: -view.y * invZoom - margin, - right: (cw - view.x) * invZoom + margin, - bottom: (ch - view.y) * invZoom + margin - }; - } - - function isApproxVisible(asset, x, y, rect) { - if (!rect) return true; - const pos = tileToWorld(x, y); - if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y); - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const w = asset.size * scale + 96; - const h = asset.size * scale + 128; - return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom; - } - - - function pickObjectAtScreen(screenX, screenY, time = performance.now()) { - const worldX = (screenX - view.x) / view.zoom; - const worldY = (screenY - view.y) / view.zoom; - const items = getDrawableItems(time, getViewportWorldRect(256)); - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - const info = getSpriteDrawInfo(item, time, false); - const margin = item.kind === 'dynamic' ? 18 : 8; - const left = info.drawX - margin; - const top = info.drawY - margin; - const right = info.drawX + info.sprite.width + margin; - const bottom = info.drawY + info.sprite.height + margin; - if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) { - return { kind: item.kind, id: item.source.id, assetId: item.asset.id }; - } - } - return null; - } - - function updateTileInfo() { - if (!els.tileInfo) return; - if (!hoverTile) { - els.tileInfo.textContent = 'Move over the map.'; - return; - } - const tile = world.get(hoverTile.x, hoverTile.y); - const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length; - const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length; - els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`; - } - - function inspectAt(x, y) { - const tile = world.get(x, y); - const staticObjects = getPlacedAtTile(x, y); - const dynamicObjects = getDynamicHomesAtTile(x, y); - const picked = [...dynamicObjects.map((p) => ({ ...p, objectKind: 'dynamic' })), ...staticObjects.map((p) => ({ ...p, objectKind: 'static' }))].at(-1); - if (picked) { - selectWorldObject(picked.objectKind, picked.id, picked.assetId, performance.now()); - const asset = findAsset(picked.assetId); - toast(asset ? `Selected ${asset.name}.` : 'Selected object.'); - } else { - selectedObject = null; - updateSelectionBubble(performance.now()); - } - const staticNames = staticObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); - const dynamicNames = dynamicObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean); - const lines = [`Tile ${x}, ${y}`, `Terrain: ${cap(tile.type)}`]; - if (staticNames.length) lines.push(`Static: ${staticNames.join(', ')}`); - if (dynamicNames.length) lines.push(`Dynamic homes: ${dynamicNames.join(', ')}`); - if (els.tileInfo) els.tileInfo.textContent = lines.join('\n'); - } - - function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) { - selectedObject = { kind, id: objectId, assetId, selectedAt }; - selectedAssetId = assetId; - updateSelectedLabel(); - renderLibrary(); - updateSelectionBubble(selectedAt); - } - - function placeSelected(x, y) { - const asset = findAsset(selectedAssetId); - if (!asset) { - toast('Select an asset first.'); - setDrawerOpen(true); - setTab('library'); - return; - } - const tile = world.get(x, y); - if (!canPlace(asset, tile)) return; - - if (asset.category === 'static') { - const existing = state.placed.find((p) => p.assetId === asset.id); - if (existing) { - existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1; - selectWorldObject('static', existing.id, asset.id, performance.now()); - toast(`${asset.name} moved.`); - } else { - const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 }; - state.placed.push(placed); - selectWorldObject('static', placed.id, asset.id, performance.now()); - toast(`${asset.name} placed.`); - } - } else { - const existing = state.dynamicSummons.find((p) => p.assetId === asset.id); - if (existing) { - existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1; - toast(`${asset.name} moved.`); - } else { - const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), version: 1 }; - state.dynamicSummons.push(summon); - toast(`${asset.name} summoned.`); - } - hydrateRuntime(); - const found = state.dynamicSummons.find((p) => p.assetId === asset.id); - if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now()); - } - const object = asset.category === 'static' - ? state.placed.find((p) => p.assetId === asset.id) - : state.dynamicSummons.find((p) => p.assetId === asset.id); - rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object)); - spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); - saveState(); - } - - function canPlace(asset, tile) { - if (!tile) return false; - if (asset.category === 'static' && asset.subtype === 'water' && tile.type !== 'water') { - toast('Water objects need water tiles.'); - return false; - } - if (asset.category === 'dynamic' && asset.subtype === 'fish' && tile.type !== 'water') { - toast('Fish need water tiles.'); - return false; - } - if (asset.category !== 'dynamic' || asset.subtype !== 'fish') { - if (tile.type === 'water' && !(asset.category === 'static' && asset.subtype === 'water')) { - toast('Use land for this asset.'); - return false; - } - } - return true; - } - - function eraseAt(x, y) { - const staticObjects = getPlacedAtTile(x, y); - const staticTarget = staticObjects.at(-1); - if (staticTarget) { - const index = state.placed.findIndex((p) => p.id === staticTarget.id); - if (index >= 0) { - const item = state.placed[index]; - const asset = findAsset(item.assetId); - state.placed.splice(index, 1); - rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id)); - if (selectedObject?.id === item.id) selectedObject = null; - saveState(); - toast(`${asset?.name ?? 'Object'} removed.`); - return; - } - } - const dynamicObjects = getDynamicHomesAtTile(x, y); - const dynamicTarget = dynamicObjects.at(-1); - if (dynamicTarget) { - const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id); - if (index >= 0) { - const item = state.dynamicSummons[index]; - const asset = findAsset(item.assetId); - state.dynamicSummons.splice(index, 1); - rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id)); - if (selectedObject?.id === item.id) selectedObject = null; - hydrateRuntime(); - saveState(); - toast(`${asset?.name ?? 'Dynamic object'} removed.`); - return; - } - } - toast('Nothing to erase here.'); - } - - - - function eraseObject(kind, objectId) { - if (kind === 'static') { - const index = state.placed.findIndex((p) => p.id === objectId); - if (index >= 0) { - const asset = findAsset(state.placed[index].assetId); - state.placed.splice(index, 1); - rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId)); - if (selectedObject?.id === objectId) selectedObject = null; - saveState(); - updateSelectionBubble(performance.now()); - toast(`${asset?.name ?? 'Object'} removed.`); - return; - } - } - if (kind === 'dynamic') { - const index = state.dynamicSummons.findIndex((p) => p.id === objectId); - if (index >= 0) { - const asset = findAsset(state.dynamicSummons[index].assetId); - state.dynamicSummons.splice(index, 1); - rebuildWorldIndex(); - recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId)); - if (selectedObject?.id === objectId) selectedObject = null; - hydrateRuntime(); - saveState(); - updateSelectionBubble(performance.now()); - toast(`${asset?.name ?? 'Dynamic object'} removed.`); - } - } - } - - function onPaintPointerDown(event) { - event.preventDefault(); - els.paintCanvas.setPointerCapture?.(event.pointerId); - lastPaintedKey = ''; - const isPan = event.button === 1 || event.button === 2; - editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY }; - isPainting = !isPan; - mousePaint.active = false; - mousePaint.panning = false; - if (isPainting) { - suppressNextPaintClick = true; - suppressMousePaintUntil = performance.now() + 350; - if (handleEditorSpecialPointerDown(event)) return; - beginEditorGesture(); - paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); - if (paintTool === 'fill' || paintTool === 'pick') { isPainting = false; finishEditorGesture(); } - } - } - - function onPaintPointerMove(event) { - if (editorPointer.pointerId !== event.pointerId) return; - if (editorPointer.panning) { - event.preventDefault(); - const rect = els.paintCanvas.getBoundingClientRect(); - const sx = els.paintCanvas.width / rect.width; - const sy = els.paintCanvas.height / rect.height; - editorView.x += (event.clientX - editorPointer.lastX) * sx; - editorView.y += (event.clientY - editorPointer.lastY) * sy; - editorPointer.lastX = event.clientX; - editorPointer.lastY = event.clientY; - clampEditorView(); - drawEditor(); - return; - } - if (shapeGesture || selectionGesture) { - event.preventDefault(); - handleEditorSpecialPointerMove(event); - return; - } - if (!isPainting) return; - if (event.buttons !== undefined && (event.buttons & 1) === 0) return; - event.preventDefault(); - paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); - } - - function onPaintPointerUp(event) { - if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false; - if (shapeGesture || selectionGesture) handleEditorSpecialPointerUp(event); - isPainting = false; - lastPaintedKey = ''; - finishEditorGesture(); - } - - function onPaintClick(event) { - if (event.button !== 0) return; - if (suppressNextPaintClick) { suppressNextPaintClick = false; return; } - event.preventDefault(); - lastPaintedKey = ''; - if (paintTool === 'line' || paintTool === 'rect' || paintTool === 'select') return; - beginEditorGesture(); - paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); - finishEditorGesture(); - } - - function onPaintWheel(event) { - event.preventDefault(); - const rect = els.paintCanvas.getBoundingClientRect(); - const scaleX = els.paintCanvas.width / rect.width; - const scaleY = els.paintCanvas.height / rect.height; - const sx = (event.clientX - rect.left) * scaleX; - const sy = (event.clientY - rect.top) * scaleY; - const worldX = (sx - editorView.x) / editorView.zoom; - const worldY = (sy - editorView.y) / editorView.zoom; - const nextZoom = clamp(editorView.zoom * (event.deltaY > 0 ? 1 / 1.12 : 1.12), 1, 24); - editorView.zoom = nextZoom; - editorView.x = sx - worldX * editorView.zoom; - editorView.y = sy - worldY * editorView.zoom; - clampEditorView(); - drawEditor(); - } - - function onPaintMouseDown(event) { - // Fallback for browsers/extensions where pointer events are swallowed. - if (event.button !== 0 && event.button !== 1 && event.button !== 2) return; - event.preventDefault(); - lastPaintedKey = ''; - const isPan = event.button === 1 || event.button === 2; - mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button }; - if (performance.now() < suppressMousePaintUntil) return; - if (mousePaint.active) { - beginEditorGesture(); - paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey); - if (paintTool === 'fill' || paintTool === 'pick') { mousePaint.active = false; finishEditorGesture(); } - } - } - - function onPaintMouseMove(event) { - if (!mousePaint.active && !mousePaint.panning) return; - event.preventDefault(); - if (mousePaint.panning) { - const rect = els.paintCanvas.getBoundingClientRect(); - const sx = els.paintCanvas.width / rect.width; - const sy = els.paintCanvas.height / rect.height; - editorView.x += (event.clientX - mousePaint.lastX) * sx; - editorView.y += (event.clientY - mousePaint.lastY) * sy; - mousePaint.lastX = event.clientX; - mousePaint.lastY = event.clientY; - clampEditorView(); - drawEditor(); - return; - } - paintAtClient(event.clientX, event.clientY, mousePaint.button || 0, event.shiftKey); - } - - function onPaintMouseUp() { - mousePaint.active = false; - mousePaint.panning = false; - lastPaintedKey = ''; - finishEditorGesture(); - } - - function paintAtEvent(event) { - paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); - } - - function paintAtClient(clientX, clientY, button = 0, shiftKey = false) { - const local = clientToPaintLocal(clientX, clientY); - const x = Math.floor(local.x); - const y = Math.floor(local.y); - if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return; - const key = `${x},${y},${paintTool},${button},${shiftKey}`; - if (key === lastPaintedKey && paintTool !== 'fill') return; - lastPaintedKey = key; - - const point = displayCellToCanonical(x, y); - const index = point.y * editorSize + point.x; - let changed = false; - - if (paintTool === 'pick') { - const picked = editorPixels[index]; - if (picked) selectPaletteCode(picked); - setPaintTool('brush'); - drawEditor(); - return; - } - - if (paintTool === 'fill') { - const displayPixels = getDisplayEditorPixels(); - const fillColor = shiftKey ? null : selectedColorCode; - const nextDisplay = EditorActions?.floodFill - ? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor) - : fallbackFloodFill(displayPixels, editorSize, x, y, fillColor); - if (nextDisplay.changed) { - editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(nextDisplay.pixels, editorSize) - : nextDisplay.pixels; - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - if (fillColor === null && Array.isArray(nextDisplay.cells)) { - for (const cell of nextDisplay.cells) { - const src = displayCellToCanonical(cell.x, cell.y); - removeLightPixel(src.x, src.y); - } - } - changed = true; - } - } else if (paintTool === 'brush') { - if (editorPixels[index] !== selectedColorCode) { - editorPixels[index] = selectedColorCode; - changed = true; - } - } else if (paintTool === 'erase') { - if (editorPixels[index] !== null) { - editorPixels[index] = null; - changed = true; - } - const before = lightPixels.length; - removeLightPixel(point.x, point.y); - changed = changed || before !== lightPixels.length; - } else if (paintTool === 'light') { - const before = JSON.stringify(lightPixels); - if (shiftKey || button === 2) removeLightPixel(point.x, point.y); - else addLightPixel(point.x, point.y, selectedColorCode); - changed = JSON.stringify(lightPixels) !== before; - updateSettingsSummary(); - } else if (paintTool === 'depth') { - const next = (shiftKey || button === 2) ? 0 : 1; - if (depthPixels[index] !== next) { - depthPixels[index] = next; - changed = true; - } - } else if (paintTool === 'door') { - if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') { - if (doorPixel.x !== x || doorPixel.y !== y) { - doorPixel = { x, y }; - changed = true; - } - updateSettingsSummary(); - } - } - - if (changed) { - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - markEditorChanged(); - drawEditor(); - } - } - - function paintEventToCell(event) { - const local = paintEventToLocal(event); - return { x: Math.floor(local.x), y: Math.floor(local.y) }; - } - - function paintEventToLocal(event) { - return clientToPaintLocal(event.clientX, event.clientY); - } - - function clientToPaintLocal(clientX, clientY) { - const rect = els.paintCanvas.getBoundingClientRect(); - const scaleX = els.paintCanvas.width / rect.width; - const scaleY = els.paintCanvas.height / rect.height; - const sx = (clientX - rect.left) * scaleX; - const sy = (clientY - rect.top) * scaleY; - const cell = els.paintCanvas.width / editorSize; - return { - x: (sx - editorView.x) / editorView.zoom / cell, - y: (sy - editorView.y) / editorView.zoom / cell - }; - } - - function snapshotEditorState() { - return { - size: editorSize, - rightPixels: [...editorPixels], - depthPixels: [...depthPixels], - lightPixels: lightPixels.map((p) => ({ ...p })), - doorPixel: { ...doorPixel }, - editingSide, - selectedColorCode, - editorSelection: editorSelection ? { ...editorSelection } : null - }; - } - - function restoreEditorState(snapshot) { - editorSize = snapshot.size || editorSize; - editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize); - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); - lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; - doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 }; - editingSide = snapshot.editingSide || editingSide; - if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; - 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); - updateSideButtons(); - renderPalette(); - drawEditor(); - updateSettingsSummary(); - updateEditorHistoryButtons(); - updateEditorSelectionButtons(); - } - - function beginEditorGesture() { - if (!editorGestureSnapshot) { - editorGestureSnapshot = snapshotEditorState(); - editorGestureChanged = false; - } - } - - function markEditorChanged() { - editorGestureChanged = true; - } - - function finishEditorGesture() { - if (!editorGestureSnapshot) return; - if (editorGestureChanged) { - editorHistory.push(editorGestureSnapshot); - if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); - editorFuture = []; - } - editorGestureSnapshot = null; - editorGestureChanged = false; - updateEditorHistoryButtons(); - } - - function commitEditorMutation(mutator) { - const before = snapshotEditorState(); - const changed = Boolean(mutator()); - if (changed) { - editorHistory.push(before); - if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift(); - editorFuture = []; - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - drawEditor(); - updateSettingsSummary(); - updateEditorHistoryButtons(); - } - return changed; - } - - function clearEditorHistory() { - editorHistory = []; - editorFuture = []; - editorGestureSnapshot = null; - editorGestureChanged = false; - updateEditorHistoryButtons(); - } - - function undoEditor() { - if (!editorHistory.length) return; - editorFuture.push(snapshotEditorState()); - restoreEditorState(editorHistory.pop()); - } - - function redoEditor() { - if (!editorFuture.length) return; - editorHistory.push(snapshotEditorState()); - restoreEditorState(editorFuture.pop()); - } - - function updateEditorHistoryButtons() { - if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0; - if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0; - updateEditorSelectionButtons(); - } - - function onEditorKeyDown(event) { - if (!els.drawer?.classList.contains('open')) return; - const key = event.key.toLowerCase(); - const activeTag = document.activeElement?.tagName?.toLowerCase(); - if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return; - if ((event.ctrlKey || event.metaKey) && key === 'z') { - event.preventDefault(); - if (event.shiftKey) redoEditor(); - else undoEditor(); - } else if ((event.ctrlKey || event.metaKey) && key === 'y') { - event.preventDefault(); - redoEditor(); - } else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) { - event.preventDefault(); - const step = event.shiftKey ? 4 : 1; - const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key]; - moveSelectionBy(delta[0], delta[1]); - } else if (key === 'escape') { - editorSelection = null; - drawEditor(); - updateEditorSelectionButtons(); - } else if (!event.ctrlKey && !event.metaKey && !event.altKey) { - const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' }; - if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); } - } - } - - function fallbackFloodFill(sourcePixels, size, x, y, colorCode) { - const pixels = [...sourcePixels]; - const target = pixels[y * size + 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 ((pixels[index] || null) !== target) continue; - pixels[index] = replacement; - cells.push({ x: cx, y: cy }); - stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]); - } - return { pixels, changed: cells.length > 0, count: cells.length, cells }; - } - - - function handleEditorSpecialPointerDown(event) { - const cell = clientToCell(event.clientX, event.clientY); - if (!cell) return false; - if (paintTool === 'line' || paintTool === 'rect') { - beginEditorGesture(); - shapeGesture = { - tool: paintTool, - startX: cell.x, - startY: cell.y, - endX: cell.x, - endY: cell.y, - erase: event.button === 2, - filled: event.shiftKey - }; - shapePreview = { ...shapeGesture }; - drawEditor(); - return true; - } - if (paintTool === 'select') { - const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection); - if (inside) { - beginEditorGesture(); - const baseSelection = { ...editorSelection }; - selectionGesture = { - mode: 'move', - startX: cell.x, - startY: cell.y, - lastDx: 0, - lastDy: 0, - baseSelection, - basePixels: getDisplayEditorPixels(), - baseDepthPixels: getDisplayDepthPixels(), - baseLightPixels: lightPixels.map((p) => ({ ...p })) - }; - } else { - selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y }; - editorSelection = normalizeSelectionRect(cell.x, cell.y, cell.x, cell.y); - updateEditorSelectionButtons(); - drawEditor(); - } - return true; - } - return false; - } - - function handleEditorSpecialPointerMove(event) { - const cell = clientToCell(event.clientX, event.clientY); - if (!cell) return; - if (shapeGesture) { - shapeGesture.endX = cell.x; - shapeGesture.endY = cell.y; - shapeGesture.filled = event.shiftKey; - shapePreview = { ...shapeGesture }; - drawEditor(); - return; - } - if (selectionGesture?.mode === 'select') { - selectionGesture.endX = cell.x; - selectionGesture.endY = cell.y; - editorSelection = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, cell.x, cell.y); - updateEditorSelectionButtons(); - drawEditor(); - return; - } - if (selectionGesture?.mode === 'move') { - let dx = cell.x - selectionGesture.startX; - let dy = cell.y - selectionGesture.startY; - ({ dx, dy } = clampSelectionDelta(selectionGesture.baseSelection, dx, dy)); - if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return; - selectionGesture.lastDx = dx; - selectionGesture.lastDy = dy; - applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy); - markEditorChanged(); - drawEditor(); - } - } - - function handleEditorSpecialPointerUp(event) { - if (shapeGesture) { - const changed = commitShapeGesture(shapeGesture); - if (changed) markEditorChanged(); - shapeGesture = null; - shapePreview = null; - drawEditor(); - return; - } - if (selectionGesture?.mode === 'select') { - const rect = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, selectionGesture.endX, selectionGesture.endY); - editorSelection = rect && rect.w * rect.h > 0 ? rect : null; - selectionGesture = null; - drawEditor(); - updateEditorSelectionButtons(); - return; - } - if (selectionGesture?.mode === 'move') { - selectionGesture = null; - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - updateEditorSelectionButtons(); - return; - } - } - - function clientToCell(clientX, clientY) { - const local = clientToPaintLocal(clientX, clientY); - const x = Math.floor(local.x); - const y = Math.floor(local.y); - if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) 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); - return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 }; - } - - function pointInSelection(x, y, selection) { - return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h; - } - - function clampSelectionDelta(selection, dx, dy) { - if (!selection) return { dx: 0, dy: 0 }; - return { - dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)), - dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h)) - }; - } - - function getDisplayDepthPixels() { - const source = normalizeDepthPixels(depthPixels, editorSize); - return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorScalarPixels(source, editorSize) - : source; - } - - function setDisplayEditorPixels(pixels) { - editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(pixels, editorSize) - : normalizePixels(pixels, editorSize); - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - } - - function setDisplayDepthPixels(pixels) { - depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorScalarPixels(pixels, 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; - } - } - return out; - } - - function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) { - const nextPixels = normalizePixels(basePixels, editorSize); - const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize); - const movedPixels = [...nextPixels]; - const movedDepth = [...nextDepth]; - for (let y = selection.y; y < selection.y + selection.h; y++) { - for (let x = selection.x; x < selection.x + selection.w; x++) { - const index = y * editorSize + x; - movedPixels[index] = null; - movedDepth[index] = 0; - } - } - for (let y = selection.y; y < selection.y + selection.h; y++) { - for (let x = selection.x; x < selection.x + selection.w; x++) { - const from = y * editorSize + x; - const to = (y + dy) * editorSize + (x + dx); - movedPixels[to] = nextPixels[from] || null; - movedDepth[to] = nextDepth[from] || 0; - } - } - setDisplayEditorPixels(movedPixels); - setDisplayDepthPixels(movedDepth); - lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy); - editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h }; - updateEditorSelectionButtons(); - } - - function moveDisplayLights(sourceLights, selection, dx, dy) { - const out = []; - for (const light of sourceLights || []) { - const shown = canonicalCellToDisplay(light.x, light.y); - if (!pointInSelection(shown.x, shown.y, selection)) { - out.push({ ...light }); - continue; - } - const nx = shown.x + dx; - const ny = shown.y + dy; - if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue; - const canonical = displayCellToCanonical(nx, ny); - out.push({ ...light, x: canonical.x, y: canonical.y }); - } - return out; - } - - function moveSelectionBy(dx, dy) { - if (!editorSelection) return false; - const bounded = clampSelectionDelta(editorSelection, dx, dy); - if (!bounded.dx && !bounded.dy) return false; - return commitEditorMutation(() => { - applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy); - return true; - }); - } - - function nudgeEditor(dx, dy) { - if (editorSelection) moveSelectionBy(dx, dy); - else shiftEditorContent(dx, dy); - } - - function shiftEditorContent(dx, dy) { - return commitEditorMutation(() => { - const selection = { x: 0, y: 0, w: editorSize, h: editorSize }; - const bounded = clampSelectionDelta(selection, dx, dy); - if (!bounded.dx && !bounded.dy) return false; - applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); - editorSelection = null; - doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) }; - return true; - }); - } - - function commitShapeGesture(gesture) { - const cells = gesture.tool === 'line' - ? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY) - : getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled); - if (!cells.length) return false; - const pixels = getDisplayEditorPixels(); - const value = gesture.erase ? null : selectedColorCode; - let changed = false; - for (const cell of cells) { - const index = cell.y * editorSize + cell.x; - if ((pixels[index] || null) !== value) { - pixels[index] = value; - changed = true; - } - if (gesture.erase) { - const canonical = displayCellToCanonical(cell.x, cell.y); - removeLightPixel(canonical.x, canonical.y); - } - } - if (!changed) return false; - setDisplayEditorPixels(pixels); - return true; - } - - function getLineCells(x0, y0, x1, y1) { - const cells = []; - let dx = Math.abs(x1 - x0); - let dy = -Math.abs(y1 - y0); - const sx = x0 < x1 ? 1 : -1; - const sy = y0 < y1 ? 1 : -1; - let err = dx + dy; - let x = x0; - let y = y0; - while (true) { - cells.push({ x, y }); - if (x === x1 && y === y1) break; - const e2 = 2 * err; - if (e2 >= dy) { err += dy; x += sx; } - if (e2 <= dx) { err += dx; y += sy; } - } - return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize); - } - - function getRectCells(x0, y0, x1, y1, filled = false) { - const rect = normalizeSelectionRect(x0, y0, x1, y1); - const cells = []; - for (let y = rect.y; y < rect.y + rect.h; y++) { - for (let x = rect.x; x < rect.x + rect.w; x++) { - if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y }); - } - } - return cells; - } - - function drawEditorOverlays(cell) { - if (shapePreview) { - const cells = shapePreview.tool === 'line' - ? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY) - : getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled); - pctx.save(); - pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42; - pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode); - for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell)); - pctx.restore(); - } - if (editorSelection) { - pctx.save(); - pctx.strokeStyle = '#ff4fa3'; - pctx.lineWidth = Math.max(2, 2 / editorView.zoom); - pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]); - pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom); - pctx.fillStyle = 'rgba(255, 79, 163, .08)'; - pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell); - pctx.restore(); - } - } - - function updateEditorSelectionButtons() { - const disabled = !editorSelection; - if (els.clearSelection) els.clearSelection.disabled = disabled; - } - - function flipEditorHorizontal() { - commitEditorMutation(() => { - editorPixels = mirrorPixels(editorPixels, editorSize); - depthPixels = mirrorScalarPixels(depthPixels, editorSize); - lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x })); - doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x }; - editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; - return true; - }); - } - - function flipEditorVertical() { - commitEditorMutation(() => { - editorPixels = flipPixelsVertical(editorPixels, editorSize); - depthPixels = flipScalarPixelsVertical(depthPixels, editorSize); - lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y })); - doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y }; - editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; - return true; - }); - } - - 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; - } - 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; - } - return out; - } - - function applyEditorOutline() { - commitEditorMutation(() => { - const source = [...editorPixels]; - const out = [...editorPixels]; - let changed = false; - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; 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]; - }); - if (adjacent) { out[index] = selectedColorCode; changed = true; } - } - } - if (!changed) return false; - editorPixels = out; - return true; - }); - } - - function exportEditorPng() { - const scale = Math.max(8, Math.floor(512 / editorSize)); - const canvas = document.createElement('canvas'); - canvas.width = editorSize * scale; - canvas.height = editorSize * 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++) { - const code = pixels[y * editorSize + x]; - if (!code) continue; - c.fillStyle = colorToHex(code); - c.fillRect(x * scale, y * scale, scale, scale); - } - } - 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.href = canvas.toDataURL('image/png'); - link.click(); - toast('PNG exported.'); - } - - function importEditorPng(event) { - const file = event.target.files?.[0]; - event.target.value = ''; - if (!file) return; - const img = new Image(); - img.onload = () => { - commitEditorMutation(() => { - const canvas = document.createElement('canvas'); - canvas.width = editorSize; - canvas.height = editorSize; - const c = canvas.getContext('2d', { willReadFrequently: true }); - c.imageSmoothingEnabled = false; - c.clearRect(0, 0, editorSize, editorSize); - c.drawImage(img, 0, 0, editorSize, editorSize); - const data = c.getImageData(0, 0, editorSize, editorSize).data; - const next = blankPixels(editorSize); - for (let i = 0; i < editorSize * editorSize; 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); - } - setDisplayEditorPixels(next); - editorSelection = null; - return true; - }); - URL.revokeObjectURL(img.src); - toast(`PNG imported into ${editorSize}×${editorSize}.`); - }; - img.onerror = () => toast('Could not read that PNG.'); - img.src = URL.createObjectURL(file); - } - - function rgbToHex(r, g, b) { - const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0'); - return `#${part(r)}${part(g)}${part(b)}`; - } - - function selectPaletteCode(code) { - if (!PALETTE_BY_CODE[code]) return; - selectedColorCode = code; - els.paintColor.value = PALETTE_BY_CODE[code]; - renderPalette(); - } - - function getActiveEditorPixels() { - return editorPixels; - } - - function getDisplayEditorPixels() { - return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic' - ? mirrorPixels(editorPixels, editorSize) - : normalizePixels(editorPixels, editorSize); - } - - function displayCellToCanonical(x, y) { - if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; - return { x, y }; - } - - function canonicalCellToDisplay(x, y) { - if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y }; - return { x, y }; - } - - 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); - 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); - doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) }; - resetEditorView(); - drawEditor(); - updateEditorHistoryButtons(); - } - - function drawEditor() { - clampEditorView(); - const canvas = els.paintCanvas; - const rectSize = canvas.width; - const cell = rectSize / editorSize; - pctx.clearRect(0, 0, rectSize, rectSize); - pctx.fillStyle = '#fffaf0'; - pctx.fillRect(0, 0, rectSize, rectSize); - - 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++) { - const color = pixels[y * editorSize + x]; - if (!color) continue; - pctx.fillStyle = colorToHex(color); - pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell)); - } - } - - if (advancedDraw && depthPixels?.length) { - if (paintTool === 'depth') { - pctx.fillStyle = 'rgba(46, 89, 160, .07)'; - pctx.fillRect(0, 0, rectSize, rectSize); - } - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; x++) { - const source = displayCellToCanonical(x, y); - const depth = depthPixels[source.y * editorSize + source.x] || 0; - if (!depth) continue; - pctx.fillStyle = 'rgba(34, 116, 255, .62)'; - pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); - pctx.strokeStyle = 'rgba(6, 28, 75, .55)'; - pctx.lineWidth = Math.max(1, 2 / editorView.zoom); - pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76)); - } - } - } - - pctx.strokeStyle = 'rgba(36, 48, 68, .13)'; - pctx.lineWidth = 1 / editorView.zoom; - for (let i = 0; i <= editorSize; 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(); - } - - { - for (const light of lightPixels) { - const shown = canonicalCellToDisplay(light.x, light.y); - const lx = (shown.x + .5) * cell; - const ly = (shown.y + .5) * cell; - pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28); - pctx.beginPath(); - pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2); - pctx.fill(); - pctx.strokeStyle = colorToHex(light.c || selectedColorCode); - pctx.lineWidth = 2 / editorView.zoom; - pctx.beginPath(); - pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2); - pctx.stroke(); - } - if (currentRole() === 'building') { - pctx.strokeStyle = '#5fb8ff'; - pctx.lineWidth = 3 / editorView.zoom; - pctx.strokeRect(doorPixel.x * cell + 2 / editorView.zoom, doorPixel.y * cell + 2 / editorView.zoom, cell - 4 / editorView.zoom, cell - 4 / editorView.zoom); - } - } - drawEditorOverlays(cell); - pctx.restore(); - } - - function resetEditorView() { - editorView.zoom = 1; - editorView.x = 0; - editorView.y = 0; - clampEditorView(); - } - - function clampEditorView() { - const size = els.paintCanvas.width; - const scaled = size * editorView.zoom; - const minOffset = size - scaled; - editorView.x = clamp(editorView.x, minOffset, 0); - editorView.y = clamp(editorView.y, minOffset, 0); - } - - 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 existing = lightPixels.find((p) => p.x === point.x && p.y === point.y); - if (existing) existing.c = colorCode; - else lightPixels.push(point); - } - - function removeLightPixel(x, y) { - lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); - } - - - function alignEditorStateToBottom(size) { - const shift = getBottomShift(editorPixels, size); - return { - rightPixels: shiftPixelsVertical(editorPixels, size, shift), - depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift), - lightPixels: lightPixels - .map((p) => ({ ...p, y: p.y + shift })) - .filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size), - door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) } - }; - } - - function getBottomShift(pixels, size) { - let maxY = -1; - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - if (pixels[y * size + x]) maxY = Math.max(maxY, y); - } - } - return maxY < 0 ? 0 : size - 1 - maxY; - } - - function shiftPixelsVertical(pixels, size, shift) { - if (!shift) return normalizePixels(pixels, size); - const out = blankPixels(size); - const source = normalizePixels(pixels, size); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - const ny = y + shift; - if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || null; - } - } - return out; - } - - function shiftDepthPixelsVertical(pixels, size, shift) { - const source = normalizeDepthPixels(pixels, size); - if (!shift) return source; - const out = Array(size * size).fill(0); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - const ny = y + shift; - if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || 0; - } - } - return out; - } - - function saveAssetFromEditor() { - const role = currentRole(); - const category = roleToCategory(role); - const subtype = roleToSubtype(role); - const size = editorSize; - const existing = editingAssetId ? findAsset(editingAssetId) : null; - const paintedDots = countPixels(editorPixels); - if (paintedDots < 10) { - toast('Draw at least 10 pixels before saving.'); - return null; - } - const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`; - const aligned = alignEditorStateToBottom(size); - const asset = { - id: existing?.id || uid(), - name, - category, - subtype, - size, - createdAt: existing?.createdAt || Date.now(), - updatedAt: Date.now(), - author: existing?.author || state.authorName || 'Local Artist', - parentAssetId: existing ? existing.parentAssetId : editParentId, - originalAssetId: existing ? existing.originalAssetId : editOriginalId, - 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) - }; - asset.contentHash = computeAssetContentHash(asset); - if (existing) { - const index = state.assets.findIndex((a) => a.id === existing.id); - if (index >= 0) state.assets[index] = asset; - toast(`${asset.name} updated.`); - } else { - state.assets.unshift(asset); - toast(`${asset.name} saved.`); - } - selectedAssetId = asset.id; - recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); - editParentId = null; - editOriginalId = null; - editingAssetId = null; - editingAssetId = asset.id; - saveState(); - spriteCache.clear(); - hydrateRuntime(); - renderLibrary(); - updateSelectedLabel(); - els.lineageNote.textContent = existing ? 'Updated the original asset.' : 'Saved as a new asset.'; - return asset; - } - - function saveAndPlaceFromEditor() { - const asset = saveAssetFromEditor(); - if (!asset) return; - selectedAssetId = asset.id; - setMode('place'); - setDrawerOpen(false); - toast('Saved. Click an island tile to summon it.'); - } - - function newAsset() { - editParentId = null; - editOriginalId = null; - els.assetName.value = ''; - els.assetCategory.value = 'nature'; - staticKind = 'nature'; - dynamicKind = 'human'; - editingSide = 'right'; - lightPixels = []; - depthPixels = blankPixels(8).map(() => 0); - doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; - setupEditor(8, blankPixels(8), null); - clearEditorHistory(); - refreshCategoryUI(); - els.lineageNote.textContent = ''; - } - - - function hydrateAuthorUI() { - state.authorName = state.authorName || 'Local Artist'; - if (els.authorName) els.authorName.value = state.authorName; - } - - function focusAssetInWorld(asset) { - const placed = state.placed.find((p) => p.assetId === asset.id); - const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id); - const target = placed ? { x: placed.x + .5, y: placed.y + .5 } : dyn ? { x: dyn.homeX + .5, y: dyn.homeY + .5 } : null; - if (!target) { - selectedObject = null; - setMode('place'); - toast('Selected. Place it on the map.'); - return; - } - const pos = tileToWorld(target.x, target.y); - pos.y -= getLiftAtCoord(target.x, target.y); - view.x = cw / 2 - pos.x * view.zoom; - view.y = ch / 2 - pos.y * view.zoom; - if (placed) selectWorldObject('static', placed.id, asset.id, performance.now()); - else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now()); - setMode('inspect'); - } - - function renderLibrary() { - els.assetList.innerHTML = ''; - const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]); - if (!visibleAssets.length) { - els.assetList.textContent = 'No visible assets.'; - renderHiddenAssets(); - return; - } - const myName = (state.authorName || 'Local Artist').trim() || 'Local Artist'; - 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)); - - addLibrarySection('My works', mine); - addLibrarySection('Others', others); - renderHiddenAssets(); - } - - function addLibrarySection(title, assets) { - const heading = document.createElement('button'); - heading.type = 'button'; - heading.className = 'librarySectionTitle'; - heading.textContent = `${title} · ${libraryFilterLabel()}`; - heading.title = 'Click to filter by genre'; - heading.addEventListener('click', () => { - cycleLibraryFilter(); - renderLibrary(); - }); - els.assetList.append(heading); - if (!assets.length) { - const empty = document.createElement('div'); - empty.className = 'libraryEmptyNote'; - empty.textContent = 'No assets in this genre.'; - els.assetList.append(empty); - return; - } - const grid = document.createElement('div'); - grid.className = 'assetSectionGrid'; - for (const asset of assets) grid.append(makeAssetCard(asset)); - els.assetList.append(grid); - } - - function libraryFilterLabel() { - return libraryFilter === 'all' ? 'All' : cap(libraryFilter); - } - - function cycleLibraryFilter() { - const filters = ['all', 'human', 'animal', 'nature', 'building', 'other']; - const index = filters.indexOf(libraryFilter); - libraryFilter = filters[(index + 1) % filters.length]; - toast(`Library filter: ${libraryFilterLabel()}`); - } - - function makeAssetCard(asset) { - const card = document.createElement('article'); - card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`; - - const preview = document.createElement('canvas'); - preview.className = 'assetPreview'; - preview.width = 64; - preview.height = 64; - drawPreview(preview, asset); - - const meta = document.createElement('div'); - meta.className = 'assetMeta'; - const lineage = asset.parentAssetId ? ' / derivative' : ''; - meta.innerHTML = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`; - meta.querySelector('strong').textContent = asset.name; - meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`; - - card.addEventListener('click', () => { - selectedAssetId = asset.id; - updateSelectedLabel(); - renderLibrary(); - focusAssetInWorld(asset); - }); - - const actions = document.createElement('div'); - actions.className = 'assetActions'; - const assetVotes = getAssetVoteCounts(asset.id); - const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0; - const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); - const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); }); - up.classList.toggle('activeVote', assetPreviousVote > 0); - down.classList.toggle('activeVote', assetPreviousVote < 0); - up.classList.toggle('mutedVote', assetPreviousVote < 0); - down.classList.toggle('mutedVote', assetPreviousVote > 0); - const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); - hide.classList.toggle('mutedAction', assetPreviousVote >= 0); - const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => { - event?.stopPropagation?.(); - copyEdit(asset); - }); - const del = makeButton('Delete', (event) => { - event?.stopPropagation?.(); - deleteAsset(asset); - }); - del.classList.add('danger'); - actions.append(up, down, hide, copy, del); - meta.append(actions); - card.append(preview, meta); - return card; - } - - function renderHiddenAssets() { - if (!els.hiddenAssetList) return; - els.hiddenAssetList.innerHTML = ''; - const hidden = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]); - if (!hidden.length) { - els.hiddenAssetList.textContent = 'No hidden assets.'; - return; - } - for (const asset of hidden) { - const row = document.createElement('article'); - row.className = 'assetCard hiddenAssetCard'; - const preview = document.createElement('canvas'); - preview.className = 'assetPreview'; - preview.width = 64; - preview.height = 64; - drawPreview(preview, asset); - const meta = document.createElement('div'); - meta.className = 'assetMeta'; - meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`; - meta.querySelector('strong').textContent = asset.name; - const actions = document.createElement('div'); - actions.className = 'assetActions'; - const restore = makeButton('Show again', (event) => { - event.stopPropagation(); - delete state.hiddenAssets[asset.id]; - saveState(); - renderLibrary(); - toast('Asset shown again.'); - }); - actions.append(restore); - meta.append(actions); - row.append(preview, meta); - els.hiddenAssetList.append(row); - } - } - - function drawPreview(canvas, asset) { - const c = canvas.getContext('2d'); - c.imageSmoothingEnabled = false; - c.clearRect(0, 0, canvas.width, canvas.height); - 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]; - if (!color) continue; - c.fillStyle = colorToHex(color); - c.fillRect(ox + x * scale, oy + y * scale, scale, scale); - } - } - } - - function renderPalette() { - if (!els.paletteGrid) return; - els.paletteGrid.innerHTML = ''; - for (const entry of PALETTE) { - const swatch = document.createElement('button'); - swatch.type = 'button'; - swatch.className = `paletteSwatch${entry.code === selectedColorCode ? ' active' : ''}`; - swatch.dataset.code = entry.code; - swatch.title = `${entry.code} ${entry.color}`; - swatch.style.background = entry.color; - swatch.addEventListener('click', () => { - selectPaletteCode(entry.code); - drawEditor(); - }); - els.paletteGrid.append(swatch); - } - els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b'; - } - - function displayCategory(asset) { - return asset.category === 'dynamic' ? 'People & Animals' : 'Buildings & Nature'; - } - - function makeButton(label, onClick) { - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = label; - button.addEventListener('click', onClick); - return button; - } - - function copyEdit(asset) { - setDrawerOpen(true); - setTab('draw'); - const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); - editingAssetId = mine ? asset.id : null; - editParentId = mine ? asset.parentAssetId : asset.id; - editOriginalId = mine ? asset.originalAssetId : (asset.originalAssetId || asset.id); - els.assetName.value = mine ? asset.name : `${asset.name} Remix`; - els.assetCategory.value = subtypeToRole(asset); - staticKind = asset.category === 'static' ? asset.subtype : staticKind; - dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; - editingSide = 'right'; - const right = getAssetPixels(asset, 'right'); - lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); - depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size); - doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 }; - setupEditor(asset.size, right, null, depthPixels); - clearEditorHistory(); - refreshCategoryUI(); - els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`; - } - - function deleteAsset(asset) { - const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); - if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return; - state.assets = state.assets.filter((a) => a.id !== asset.id); - state.placed = state.placed.filter((p) => p.assetId !== asset.id); - state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id); - if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null; - saveState(); - spriteCache.clear(); - hydrateRuntime(); - renderLibrary(); - updateSelectedLabel(); - } - - function updateSelectedLabel() { - if (!els.selectedAssetName) return; - const asset = findAsset(selectedAssetId); - els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none'; - } - - function hydrateRuntime() { - dynamicRuntime = state.dynamicSummons.map((summon) => { - const asset = findAsset(summon.assetId); - if (!asset) return null; - const homeTile = world.get(Math.round(summon.homeX), Math.round(summon.homeY)); - const homeValid = homeTile && ((asset.subtype === 'fish') ? homeTile.type === 'water' : homeTile.type !== 'water'); - const spawn = homeValid ? { x: Math.round(summon.homeX), y: Math.round(summon.homeY) } : findValidSpawn(asset, summon.homeX, summon.homeY, 6); - return { - id: summon.id, - assetId: summon.assetId, - homeX: summon.homeX, - homeY: summon.homeY, - x: spawn.x + .5, - y: spawn.y + .5, - targetX: spawn.x + .5, - targetY: spawn.y + .5, - vx: 1, - hiddenUntil: 0, - seed: Math.random() * 9999, - nextDecisionAt: 0, - nextBubbleAt: 800 + Math.random() * 1500 - }; - }).filter(Boolean); - } - - function findValidSpawn(asset, homeX, homeY, radius) { - if (asset.subtype === 'bird') { - const x = clamp(Math.round(homeX), 0, WORLD_W - 1); - const y = clamp(Math.round(homeY), 0, WORLD_H - 1); - if (world.get(x, y)?.type !== 'water') return { x, y }; - } - const candidates = []; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const x = Math.round(homeX + dx); - const y = Math.round(homeY + dy); - if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) continue; - const tile = world.get(x, y); - if (asset.subtype === 'fish') { - if (tile.type === 'water') candidates.push({ x, y }); - } else if (tile.type !== 'water') candidates.push({ x, y }); - } - } - if (!candidates.length) return { x: clamp(Math.round(homeX), 0, WORLD_W - 1), y: clamp(Math.round(homeY), 0, WORLD_H - 1) }; - return candidates[Math.floor(Math.random() * candidates.length)]; - } - - function updateDynamicRuntime(dt, time) { - bubbleParticles = bubbleParticles.filter((p) => time - p.started < p.life); - confettiParticles = confettiParticles.filter((p) => time - p.started < p.life); - for (const item of dynamicRuntime) { - const asset = findAsset(item.assetId); - if (!asset) continue; - if (time < item.hiddenUntil) continue; - if (asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) { - spawnFishBubbleCluster(item.x, item.y, time, item.seed); - item.nextBubbleAt = time + 1100 + Math.random() * 1800; - } - const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); - if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); - - const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; - const dx = item.targetX - item.x; - const dy = item.targetY - item.y; - const len = Math.hypot(dx, dy) || 1; - item.vx = dx === 0 ? item.vx : Math.sign(dx); - item.x += (dx / len) * speed; - item.y += (dy / len) * speed; - - if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) { - item.hiddenUntil = time + 1700 + Math.random() * 2200; - } - } - } - - function chooseTarget(item, asset, time) { - let target = null; - if (asset.subtype === 'human') target = findNearestPlaced(item.x, item.y, 'building', 14); - else if (asset.subtype === 'animal' || asset.subtype === 'bird') target = findNearestPlaced(item.x, item.y, 'nature', 16); - - if (target && Math.random() < .72) { - item.targetX = target.x + .5 + (Math.random() - .5) * 1.8; - item.targetY = target.y + .5 + (Math.random() - .5) * 1.8; - } else { - const spawn = findValidSpawn(asset, item.homeX, item.homeY, asset.subtype === 'bird' ? 2 : 7); - item.targetX = spawn.x + .5; - item.targetY = spawn.y + .5; - } - item.nextDecisionAt = time + 1600 + Math.random() * 2800; - } - - function findNearestPlaced(x, y, subtype, maxDistance) { - let best = null; - let bestDistance = maxDistance; - const minX = clampInt(Math.floor(x - maxDistance), 0, WORLD_W - 1, 0); - const maxX = clampInt(Math.ceil(x + maxDistance), 0, WORLD_W - 1, WORLD_W - 1); - const minY = clampInt(Math.floor(y - maxDistance), 0, WORLD_H - 1, 0); - const maxY = clampInt(Math.ceil(y + maxDistance), 0, WORLD_H - 1, WORLD_H - 1); - for (let ty = minY; ty <= maxY; ty++) { - for (let tx = minX; tx <= maxX; tx++) { - const bucket = worldIndex.placedByTile.get(tileKey(tx, ty)); - if (!bucket) continue; - for (const placed of bucket) { - const asset = findAsset(placed.assetId); - if (!asset || asset.subtype !== subtype) continue; - const distance = Math.hypot(placed.x + .5 - x, placed.y + .5 - y); - if (distance < bestDistance) { - bestDistance = distance; - best = placed; - } - } - } - } - return best; - } - - - function scheduleFrame() { - if (animationFrameId || document.hidden) return; - animationFrameId = requestAnimationFrame(tick); - } - - function onVisibilityChange() { - if (document.hidden) { - if (animationFrameId) cancelAnimationFrame(animationFrameId); - animationFrameId = 0; - lastRuntimeUpdate = performance.now(); - dynamicLogicRemainder = 0; - return; - } - lastRuntimeUpdate = performance.now(); - dynamicLogicRemainder = 0; - render(lastRuntimeUpdate); - scheduleFrame(); - } - - function tick(time) { - animationFrameId = 0; - if (document.hidden) { - lastRuntimeUpdate = time; - return; - } - const elapsed = Math.min(100, Math.max(0, time - lastRuntimeUpdate)); - lastRuntimeUpdate = time; - dynamicLogicRemainder += elapsed; - let steps = 0; - while (dynamicLogicRemainder >= DYNAMIC_LOGIC_STEP_MS && steps < MAX_DYNAMIC_STEPS_PER_FRAME) { - updateDynamicRuntime(DYNAMIC_LOGIC_STEP_MS / 1000, time); - dynamicLogicRemainder -= DYNAMIC_LOGIC_STEP_MS; - steps++; - } - if (steps >= MAX_DYNAMIC_STEPS_PER_FRAME) dynamicLogicRemainder = 0; - updateClock(); - render(time); - scheduleFrame(); - } - - function updateClock() { - const phase = getPhase(); - const second = Math.floor(Date.now() / 1000); - if (second === lastClockSecond) return; - lastClockSecond = second; - els.phaseLabel.textContent = phase.label; - els.phaseBar.style.width = `${Math.round(phase.progress * 100)}%`; - } - - function getShadowForMinute(minute) { - const isNight = minute >= 6; - const local = isNight ? (minute - 6) / 4 : minute / 6; - const eased = clamp(local, 0, 1); - const elevation = Math.sin(eased * Math.PI); - // Morning: down-left, Noon: straight down, Evening: down-right. - // The top edge of the shadow is always anchored to the sprite's bottom edge. - const dirX = lerp(-1.0, 1.0, eased); - const length = lerp(1.35, 0.58, elevation); - return { - dirX, - length, - skewX: dirX * (0.72 + 0.32 * length), - scaleY: 0.24 + 0.06 * length, - alpha: isNight ? .12 : lerp(.28, .16, elevation) - }; - } - - function getPhase() { - const t = mod(Date.now(), DAY_MS); - const minute = t / 60000; - const stops = [ - { at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] }, - { at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] }, - { at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] } - ]; - let a = stops[0], b = stops[1]; - for (let i = 0; i < stops.length - 1; i++) { - if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; } - if (minute >= 6) { a = stops[3]; b = stops[4]; } - } - const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); - const eased = localT * localT * (3 - 2 * localT); - const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); - const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night'; - return { - key: label.toLowerCase(), - label, - progress: t / DAY_MS, - darkness: lerp(a.darkness, b.darkness, eased), - tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, - shadow: getShadowForMinute(minute) - }; - } - - function render(time = performance.now()) { - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, cw, ch); - ctx.fillStyle = '#86d5ff'; - ctx.fillRect(0, 0, cw, ch); - const phase = getPhase(); - renderPhase = phase; - - ctx.save(); - ctx.translate(view.x, view.y); - ctx.scale(view.zoom, view.zoom); - ctx.imageSmoothingEnabled = false; - drawTerrainCache(terrainCache); - drawHoverTile(); - const lightSources = []; - drawObjects(time, lightSources, phase); - drawBubbleParticles(time); - drawSpawnEffects(time); - drawConfettiParticles(time); - ctx.restore(); - - updateSelectionBubble(time); - if (phase.tint !== 'rgba(255, 255, 255, 0)') { - ctx.fillStyle = phase.tint; - ctx.fillRect(0, 0, cw, ch); - } - if (phase.darkness > 0) { - ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`; - ctx.fillRect(0, 0, cw, ch); - drawLightSources(lightSources, phase.darkness); - } - } - - function drawHoverTile() { - if (!hoverTile) return; - const { x, y } = tileToWorld(hoverTile.x, hoverTile.y); - const lift = getTileLift(hoverTile.tile); - ctx.beginPath(); - ctx.moveTo(x, y - lift); - ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); - ctx.lineTo(x, y + TILE_H - lift); - ctx.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift); - ctx.closePath(); - ctx.strokeStyle = 'rgba(255, 255, 255, .7)'; - ctx.lineWidth = 1 / view.zoom; - ctx.stroke(); - } - - function drawSpawnEffects(time) { - if (!spawnEffects.length) return; - spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650); - for (const effect of spawnEffects) { - const t = clamp((time - effect.started) / 650, 0, 1); - const pos = tileToWorld(effect.x, effect.y); - pos.y -= getLiftAtCoord(effect.x, effect.y); - const radius = 6 + t * 24; - ctx.save(); - ctx.globalAlpha = 1 - t; - ctx.lineWidth = Math.max(1, 3 / view.zoom); - ctx.strokeStyle = '#ff79ad'; - ctx.beginPath(); - ctx.arc(pos.x, pos.y + TILE_H * .35, radius, 0, Math.PI * 2); - ctx.stroke(); - ctx.fillStyle = '#fff2a8'; - for (let i = 0; i < 5; i++) { - const a = i * Math.PI * .4 + t * 2.4; - ctx.fillRect(pos.x + Math.cos(a) * radius - 2, pos.y + TILE_H * .35 + Math.sin(a) * radius - 2, 4, 4); - } - ctx.restore(); - } - } - - function drawObjects(time, lightSources, phase) { - const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); - - for (const item of items) { - if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { - drawSpriteItem(item, time, lightSources, true, phase); - } - } - for (const item of items) { - if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) { - drawSpriteItem(item, time, lightSources, false, phase); - } - } - } - - function getSpriteDrawInfo(item, time, includeSelectBounce = true) { - const asset = item.asset; - const pos = tileToWorld(item.x, item.y); - const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird'); - if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y); - let bob = 0; - if (includeSelectBounce && asset.category === 'static' && selectedObject?.id === item.source?.id) { - const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 520, 0, 1); - if (selectedT < 1) bob += -Math.sin(selectedT * Math.PI) * 8; - } - let alpha = 1; - let side = 'right'; - let angle = 0; - if (asset.category === 'static' && asset.subtype === 'water') bob += Math.sin(time / 900 + item.x * .7) * 1.6; - if (asset.category === 'dynamic') { - const runtime = item.source; - side = runtime.vx < 0 ? 'left' : 'right'; - if (asset.subtype === 'human' || asset.subtype === 'animal') { - const jumpPhase = time / 78 + runtime.seed * 0.9; - const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); - const liftNow = getLiftAtCoord(item.x, item.y); - const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); - const terrainBoost = Math.abs(liftTarget - liftNow) / 12; - bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); - const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); - const rand = pseudoNoise(cycle + runtime.seed * 13.17); - angle = (rand - 0.5) * 0.24 * hop; - } - if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5; - if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; } - } - const sprite = getSpriteCanvas(asset, side); - const drawX = pos.x - sprite.width / 2; - const drawY = pos.y + TILE_H / 2 - sprite.height + bob; - return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob }; - } - - function drawSpriteItem(item, time, lightSources, underwater, phase) { - const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); - - if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); - - ctx.save(); - ctx.globalAlpha = alpha; - if (angle) { - ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8)); - ctx.rotate(angle); - ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); - } else { - ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY)); - } - if (underwater) { - ctx.fillStyle = 'rgba(103, 181, 217, .18)'; - if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height); - else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height); - } - ctx.restore(); - - if (asset.meta?.hasLight && asset.meta.lightPixels?.length) { - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - for (const light of asset.meta.lightPixels) { - const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x; - const wx = drawX + (lx + 0.5) * scale; - const wy = drawY + (light.y + 0.5) * scale; - lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(12, 20, asset.size / 64) }); - } - } - } - - function drawFishBubbles(x, y, time, seed) { - const cycle = Math.floor((time + seed * 37) / 1300); - if (pseudoNoise(cycle + seed) < 0.55) return; - const t = ((time + seed * 37) % 1300) / 1300; - ctx.save(); - ctx.fillStyle = 'rgba(225, 252, 255, .58)'; - ctx.globalAlpha = Math.max(0, 1 - t); - for (let i = 0; i < 4; i++) { - const ox = Math.round((pseudoNoise(cycle + seed + i * 4.1) - .5) * 7); - const oy = Math.round(-t * 12 - i * 3); - ctx.fillRect(Math.round(x + ox), Math.round(y + oy), 1, 1); - } - ctx.restore(); - } - - - - function drawSpriteShadow(drawX, drawY, sprite, phase) { - const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 }; - const silhouette = getShadowCanvas(sprite); - const contactX = drawX + sprite.width / 2; - const contactY = drawY + sprite.height; - - ctx.save(); - ctx.globalAlpha = shadow.alpha; - // The silhouette is vertically flipped in getShadowCanvas(). - // Drawing it at y=0 means the flipped top edge, which was the sprite's bottom edge, - // exactly touches the sprite's bottom/contact line. - ctx.translate(contactX, contactY); - ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); - ctx.drawImage(silhouette, Math.round(-sprite.width / 2), 0); - ctx.restore(); - } - - function getShadowCanvas(sprite) { - if (shadowCanvasCache.has(sprite)) return shadowCanvasCache.get(sprite); - const canvas = document.createElement('canvas'); - canvas.width = sprite.width; - canvas.height = sprite.height; - const c = canvas.getContext('2d'); - c.imageSmoothingEnabled = false; - c.save(); - c.translate(0, sprite.height); - c.scale(1, -1); - c.drawImage(sprite, 0, 0); - c.restore(); - c.globalCompositeOperation = 'source-in'; - c.fillStyle = '#1b1f26'; - c.fillRect(0, 0, canvas.width, canvas.height); - shadowCanvasCache.set(sprite, canvas); - return canvas; - } - - - function currentVoterKey() { - return ((state.authorName || els.authorName?.value || 'Local Artist').trim() || 'Local Artist').toLowerCase(); - } - - function getObjectVoteCounts(objectId) { - state.objectVotes ||= {}; - const votes = state.objectVotes[objectId] || { up: 0, down: 0, voters: {} }; - votes.voters ||= {}; - return votes; - } - - function getAssetVoteCounts(assetId) { - state.assetVotes ||= {}; - const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} }; - votes.voters ||= {}; - return votes; - } - - function voteAsset(assetId, delta) { - state.assetVotes ||= {}; - const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} }; - votes.voters ||= {}; - const voter = currentVoterKey(); - const previous = votes.voters[voter] || 0; - if (previous > 0) votes.up = Math.max(0, votes.up - 1); - if (previous < 0) votes.down = Math.max(0, votes.down - 1); - if (previous === delta) delete votes.voters[voter]; - else { - if (delta > 0) votes.up += 1; - else votes.down += 1; - votes.voters[voter] = delta; - } - state.assetVotes[assetId] = votes; - saveState(); - renderLibrary(); - } - - function hideAsset(assetId) { - state.hiddenAssets ||= {}; - state.hiddenAssets[assetId] = true; - if (selectedAssetId === assetId) selectedAssetId = state.assets.find((a) => !state.hiddenAssets?.[a.id])?.id || null; - if (selectedObject?.assetId === assetId) selectedObject = null; - saveState(); - renderLibrary(); - updateSelectionBubble(performance.now()); - toast('Asset hidden locally.'); - } - - function getRemixCount(assetId) { - return state.assets.filter((asset) => asset.parentAssetId === assetId || asset.originalAssetId === assetId).length; - } - - function countPixels(pixels) { - return Array.isArray(pixels) ? pixels.filter(Boolean).length : normalizePixels(pixels || [], editorSize).filter(Boolean).length; - } - - function getSelectedWorldPosition() { - if (!selectedObject) return null; - const asset = findAsset(selectedObject.assetId); - if (!asset) return null; - if (selectedObject.kind === 'static') { - const placed = state.placed.find((p) => p.id === selectedObject.id); - if (!placed) return null; - const pos = tileToWorld(placed.x + .5, placed.y + .5); - pos.y -= getLiftAtCoord(placed.x + .5, placed.y + .5); - return { pos, asset, objectId: placed.id }; - } - const runtime = dynamicRuntime.find((r) => r.id === selectedObject.id); - const summon = state.dynamicSummons.find((s) => s.id === selectedObject.id); - const source = runtime || (summon ? { x: summon.homeX + .5, y: summon.homeY + .5 } : null); - if (!source) return null; - const pos = tileToWorld(source.x, source.y); - if (asset.subtype !== 'bird') pos.y -= getLiftAtCoord(source.x, source.y); - return { pos, asset, objectId: selectedObject.id }; - } - - function updateSelectionBubble(time) { - if (!els.selectionBubble) return; - const selected = getSelectedWorldPosition(); - if (!selected) { - els.selectionBubble.hidden = true; - return; - } - const { pos, asset, objectId } = selected; - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const topOffset = Math.max(18, asset.size * scale + 26); - const sx = pos.x * view.zoom + view.x; - const sy = (pos.y - topOffset) * view.zoom + view.y; - if (sx < -100 || sy < -100 || sx > cw + 100 || sy > ch + 100) { - els.selectionBubble.hidden = true; - return; - } - els.selectionBubble.hidden = false; - els.selectionBubble.style.transform = `translate(${Math.round(sx)}px, ${Math.round(sy)}px) 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; - if (els.bubbleRemixFrom) { - els.bubbleRemixFrom.hidden = !parent; - els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : ''; - } - if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`; - const votes = getObjectVoteCounts(objectId); - const voter = currentVoterKey(); - const previous = votes.voters?.[voter] || 0; - els.voteScore.textContent = `${votes.up - votes.down}`; - els.voteUp.classList.toggle('activeVote', previous > 0); - els.voteDown.classList.toggle('activeVote', previous < 0); - els.voteUp.classList.toggle('mutedVote', previous < 0); - els.voteDown.classList.toggle('mutedVote', previous > 0); - if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0; - } - - function voteSelected(delta) { - if (!selectedObject) return; - state.objectVotes ||= {}; - const key = selectedObject.id; - const votes = state.objectVotes[key] || { up: 0, down: 0, voters: {} }; - votes.voters ||= {}; - const voter = currentVoterKey(); - const previous = votes.voters[voter] || 0; - if (previous > 0) votes.up = Math.max(0, votes.up - 1); - if (previous < 0) votes.down = Math.max(0, votes.down - 1); - if (previous === delta) { - delete votes.voters[voter]; - } else { - if (delta > 0) { - votes.up += 1; - spawnConfettiAtSelection(performance.now()); - } else { - votes.down += 1; - } - votes.voters[voter] = delta; - } - state.objectVotes[key] = votes; - saveState(); - updateSelectionBubble(performance.now()); - renderLibrary(); - // Server moderation TODO: when online, hide or delete objects from the server if downvotes exceed a threshold - // such as down >= 5 and down - up >= 3. Do not implement server deletion in this local prototype. - } - - function remixSelected() { - if (!selectedObject) return; - const asset = findAsset(selectedObject.assetId); - if (asset) copyEdit(asset); - } - - function hideSelected() { - if (!selectedObject) return; - state.hiddenObjects ||= {}; - state.hiddenObjects[selectedObject.id] = true; - const assetId = selectedObject.assetId; - selectedObject = null; - saveState(); - updateSelectionBubble(performance.now()); - renderLibrary(); - toast('Hidden locally.'); - // Server moderation TODO: when synced, repeated low-rating + hide actions can flag this object for review. - } - - function spawnFishBubbleCluster(tileX, tileY, time, seed = 0) { - const count = 2 + Math.floor(pseudoNoise(time * 0.003 + seed) * 3); - for (let i = 0; i < count; i++) { - bubbleParticles.push({ - x: tileX + (pseudoNoise(seed + time * .01 + i * 7.1) - .5) * .22, - y: tileY + (pseudoNoise(seed + time * .013 + i * 5.7) - .5) * .22, - started: time + i * 45, - life: 900 + pseudoNoise(seed + i * 3.3) * 550, - drift: (pseudoNoise(seed + i * 9.9) - .5) * .08 - }); - } - } - - function drawBubbleParticles(time) { - if (!bubbleParticles.length) return; - ctx.save(); - ctx.fillStyle = 'rgba(225, 252, 255, .62)'; - for (const particle of bubbleParticles) { - const age = (time - particle.started) / particle.life; - if (age < 0 || age > 1) continue; - const pos = tileToWorld(particle.x + particle.drift * age, particle.y); - pos.y -= 3 + age * 18; - ctx.globalAlpha = 1 - age; - ctx.fillRect(Math.round(pos.x), Math.round(pos.y), 1, 1); - } - ctx.restore(); - } - - function spawnConfettiAtSelection(time) { - const selected = getSelectedWorldPosition(); - if (!selected) return; - const palette = ['a', 'f', 'l', 's', 'A', 'H', 'P', 'Z']; - for (let i = 0; i < 22; i++) { - const angle = (Math.PI * 2 * i) / 22 + pseudoNoise(time + i) * .4; - 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, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed - 25, - color: palette[i % palette.length], - started: time, - life: 850 + pseudoNoise(i + time) * 450 - }); - } - } - - function drawConfettiParticles(time) { - if (!confettiParticles.length) return; - ctx.save(); - for (const particle of confettiParticles) { - const ageMs = time - particle.started; - const t = ageMs / particle.life; - if (t < 0 || t > 1) continue; - const seconds = ageMs / 1000; - const x = particle.x + particle.vx * seconds; - const y = particle.y + particle.vy * seconds + 48 * seconds * seconds; - ctx.globalAlpha = 1 - t; - ctx.fillStyle = colorToHex(particle.color); - ctx.fillRect(Math.round(x), Math.round(y), 3, 3); - } - ctx.restore(); - } - - function drawLightSources(sources, darkness) { - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; - for (const source of sources) { - const sx = source.x * view.zoom + view.x; - const sy = source.y * view.zoom + view.y; - const radius = source.radius * view.zoom; - if (sx < -radius || sy < -radius || sx > cw + radius || sy > ch + radius) continue; - const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius); - gradient.addColorStop(0, hexToRgba(source.color, .18 + darkness * .10)); - gradient.addColorStop(.35, hexToRgba(source.color, .08)); - gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = gradient; - ctx.beginPath(); - ctx.arc(sx, sy, radius, 0, Math.PI * 2); - ctx.fill(); - } - ctx.restore(); - } - - function getSpriteCanvas(asset, side) { - const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}`; - if (spriteCache.has(key)) return spriteCache.get(key); - const pixels = getAssetPixels(asset, side); - const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; - const canvas = document.createElement('canvas'); - canvas.width = asset.size * scale; - canvas.height = asset.size * 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]; - if (!color) continue; - c.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y, side)); - c.fillRect(x * scale, y * scale, scale, scale); - } - } - spriteCache.set(key, canvas); - return canvas; - } - - 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); - return right; - } - - function makeWorld() { - const islands = [ - { cx: 36, cy: 34, rx: 32, ry: 22, height: 1.04 }, - { cx: 92, cy: 30, rx: 34, ry: 20, height: .98 }, - { cx: 74, cy: 76, rx: 42, ry: 24, height: 1.02 }, - { cx: 124, cy: 72, rx: 16, ry: 14, height: .92 }, - { cx: 26, cy: 86, rx: 16, ry: 12, height: .9 }, - { cx: 118, cy: 102, rx: 20, ry: 9, height: .86 } - ]; - const tiles = []; - for (let y = 0; y < WORLD_H; y++) { - for (let x = 0; x < WORLD_W; x++) { - const continent = fractalPerlin(x * .035, y * .035, 4); - const detail = fractalPerlin(x * .095 + 41.7, y * .095 - 13.2, 3); - let score = -1.2; - for (const island of islands) { - const warpX = (fractalPerlin(x * .055 + island.cx, y * .055, 2) - .5) * 7.0; - const warpY = (fractalPerlin(x * .055, y * .055 + island.cy, 2) - .5) * 5.0; - const dx = (x + warpX - island.cx) / island.rx; - const dy = (y + warpY - island.cy) / island.ry; - const base = 1 - (dx * dx + dy * dy); - score = Math.max(score, base * island.height); - } - score += (continent - .5) * .36 + (detail - .5) * .18; - let type = 'water'; - if (score > .015) type = 'sand'; - if (score > .17) type = 'grass'; - if (score > .56 && fractalPerlin(x * .16 + 8, y * .16 + 4, 2) > .56) type = 'highland'; - const heightLevel = type === 'highland' ? 1 : 0; - tiles.push({ x, y, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 }); - } - } - return { - tiles, - get(x, y) { - if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) return null; - return tiles[y * WORLD_W + x]; - } - }; - } - - function buildTerrainCache(worldData) { - const chunkSize = TERRAIN_CHUNK_SIZE; - const chunks = []; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - 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) 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) 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); - minY = Math.min(minY, bounds.y); - maxX = Math.max(maxX, bounds.x + canvas.width); - maxY = Math.max(maxY, bounds.y + canvas.height); - } - } - if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize }; - return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize }; - } - - function getTerrainChunkBounds(tiles) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const tile of tiles) { - const p = tileToWorld(tile.x, tile.y); - const lift = getTileLift(tile); - minX = Math.min(minX, p.x - TILE_W / 2 - 3); - maxX = Math.max(maxX, p.x + TILE_W / 2 + 3); - minY = Math.min(minY, p.y - lift - 16); - maxY = Math.max(maxY, p.y + TILE_H + 18); - } - minX = Math.floor(minX); - minY = Math.floor(minY); - maxX = Math.ceil(maxX); - maxY = Math.ceil(maxY); - return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) }; - } - - function drawTerrainCache(cache) { - 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 drawTerrainTile(c, tile, worldData) { - const { x, y } = tileToWorld(tile.x, tile.y); - const palette = { - water: ['#6ebfe0', '#69bbdc'], - sand: ['#ead99d', '#e8d293'], - grass: ['#91cf76', '#89c970'], - highland: ['#9db781', '#95ab79'] - }; - const [base, alt] = palette[tile.type]; - const fill = tile.shade > 0 ? base : alt; - const lift = getTileLift(tile); - - if (tile.type === 'highland') { - 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(); - } - } - - c.fillStyle = fill; - c.beginPath(); - c.moveTo(x, y - lift); - c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); - c.lineTo(x, y + TILE_H - lift); - c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift); - c.closePath(); - c.fill(); - - if (tile.type === 'water') { - c.fillStyle = 'rgba(255,255,255,.045)'; - c.beginPath(); - c.moveTo(x - TILE_W * .20, y + TILE_H * .48); - c.lineTo(x + TILE_W * .15, y + TILE_H * .31); - c.lineTo(x + TILE_W * .25, y + TILE_H * .38); - c.lineTo(x - TILE_W * .10, y + TILE_H * .55); - c.closePath(); - c.fill(); - } - - c.strokeStyle = 'rgba(36, 48, 68, .02)'; - c.lineWidth = 1; - c.beginPath(); - c.moveTo(x, y - lift); - c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); - c.lineTo(x, y + TILE_H - lift); - c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift); - c.closePath(); - c.stroke(); - } - - function getTileLift(tile) { - return tile?.type === 'highland' ? 12 : 0; - } - - function getLiftAtCoord(x, y) { - const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1)); - return getTileLift(tile); - } - - function loadState() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - const expanded = Phase2Sync?.isCompactState?.(parsed) ? Phase2Sync.expandState(parsed) : parsed; - if (expanded && Array.isArray(expanded.assets)) return normalizeState(expanded); - } - } catch (error) { - console.warn('Could not load local state.', error); - } - return seedState(); - } - - function saveState() { - state.schema = SAVE_SCHEMA; - state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : []; - const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; - localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); - cachePhase2State(); - updateSyncStats(); - } - - function normalizeState(input) { - return { - schema: SAVE_SCHEMA, - authorName: input.authorName || 'Local Artist', - assets: 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 || {}, - assetVotes: input.assetVotes || {}, - hiddenAssets: input.hiddenAssets || {}, - hiddenObjects: input.hiddenObjects || {}, - eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], - sync: input.sync || { lastEventId: null } - }; - } - - function normalizeAsset(asset) { - const size = Number(asset.size) || 16; - const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; - const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size); - const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other'); - const normalized = { - id: asset.id || uid(), - name: asset.name || 'Untitled', - category, - subtype, - size, - pixels: encodePixels(right), - faces: category === 'dynamic' ? { right: encodePixels(right), 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) - }; - normalized.contentHash = computeAssetContentHash(normalized); - return normalized; - } - - function normalizeAssetMeta(meta, category, subtype, size) { - const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size); - 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') })) - .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) - : []; - return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size); - } - - function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize) { - const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size); - 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 hasLight = lightPixelsClean.length > 0; - return { - hasLight, - lightPixels: hasLight ? lightPixelsClean : [], - lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, - 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 - }; - } - - function normalizePlacements(items) { - return dedupeByAsset(items).map((item) => ({ - id: item.id || uid(), - assetId: item.assetId, - x: clampInt(item.x, 0, WORLD_W - 1, 0), - y: clampInt(item.y, 0, WORLD_H - 1, 0), - placedAt: item.placedAt || item.createdAt || Date.now(), - version: Number(item.version) || 1 - })).filter((item) => item.assetId); - } - - function normalizeDynamicSummons(items) { - return dedupeByAsset(items).map((item) => ({ - id: item.id || uid(), - assetId: item.assetId, - homeX: clampInt(item.homeX ?? item.x, 0, WORLD_W - 1, 0), - homeY: clampInt(item.homeY ?? item.y, 0, WORLD_H - 1, 0), - createdAt: item.createdAt || item.placedAt || Date.now(), - version: Number(item.version) || 1 - })).filter((item) => item.assetId); - } - - function dedupeByAsset(items) { - const map = new Map(); - for (const item of items) { - if (item && item.assetId) map.set(item.assetId, item); - } - return [...map.values()]; - } - - function findNearestTerrain(type, startX, startY) { - let best = null; - let bestD = Infinity; - const maxR = Math.max(WORLD_W, WORLD_H); - for (let r = 0; r < maxR; r++) { - for (let y = Math.max(0, startY - r); y <= Math.min(WORLD_H - 1, startY + r); y++) { - for (let x = Math.max(0, startX - r); x <= Math.min(WORLD_W - 1, startX + r); x++) { - const tile = world.get(x, y); - if (!tile || tile.type !== type) continue; - const d = Math.hypot(x - startX, y - startY); - if (d < bestD) { bestD = d; best = { x, y }; } - } - } - if (best) return best; - } - return { x: startX, y: startY }; - } - - function homeFromPos(pos) { - return { homeX: pos.x, homeY: pos.y }; - } - - function seedState() { - const assets = [ - makeAsset('Cozy House', 'static', 'building', drawHouse(), { hasLight: true, lightPixels: [{ x: 10, y: 8 }, { x: 11, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }), - makeAsset('Round Tree', 'static', 'nature', drawTree()), - makeAsset('Dock Lamp', 'static', 'water', drawLantern(), { hasLight: true, lightPixels: [{ x: 7, y: 6 }, { x: 8, y: 6 }], lightColor: '#b8e8ff' }), - makeAsset('Traveler', 'dynamic', 'human', drawHumanRight(), {}, drawHumanLeft()), - makeAsset('Island Pup', 'dynamic', 'animal', drawDogRight(), {}, drawDogLeft()), - makeAsset('Blue Fish', 'dynamic', 'fish', drawFishRight(), {}, drawFishLeft()), - makeAsset('Tiny Bird', 'dynamic', 'bird', drawBirdRight(), {}, drawBirdLeft()) - ]; - - const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id])); - return { - schema: SAVE_SCHEMA, - authorName: 'Local Artist', - assets, - placed: [ - { id: uid(), assetId: idByName['Cozy House'], x: 36, y: 36, placedAt: Date.now() }, - { id: uid(), assetId: idByName['Round Tree'], x: 32, y: 36, placedAt: Date.now() }, - { id: uid(), assetId: idByName['Dock Lamp'], ...findNearestTerrain('water', 62, 58), placedAt: Date.now() } - ], - objectVotes: {}, - assetVotes: {}, - hiddenAssets: {}, - hiddenObjects: {}, - dynamicSummons: [ - { id: uid(), assetId: idByName['Traveler'], homeX: 36, homeY: 38, createdAt: Date.now() }, - { id: uid(), assetId: idByName['Island Pup'], homeX: 32, homeY: 40, createdAt: Date.now() }, - { id: uid(), assetId: idByName['Blue Fish'], ...homeFromPos(findNearestTerrain('water', 63, 60)), createdAt: Date.now() }, - { id: uid(), assetId: idByName['Tiny Bird'], homeX: 90, homeY: 30, createdAt: Date.now() } - ] - }; - } - - function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) { - const size = 16; - const id = uid(); - const right = alignPixelsToBottom(normalizePixels(pixels, size), size); - const asset = { - id, - name, - category, - subtype, - size, - pixels: encodePixels(right), - faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null, - parentAssetId: null, - originalAssetId: null, - createdAt: Date.now(), - updatedAt: Date.now(), - author: 'Island Team', - meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size) - }; - asset.contentHash = computeAssetContentHash(asset); - return asset; - } - - - function alignPixelsToBottom(pixels, size) { - const source = normalizePixels(pixels, size); - let maxY = -1; - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - if (source[y * size + x]) maxY = Math.max(maxY, y); - } - } - if (maxY < 0 || maxY === size - 1) return source; - const dy = size - 1 - maxY; - const out = blankPixels(size); - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - const value = source[y * size + x]; - if (value && y + dy < size) out[(y + dy) * size + x] = value; - } - } - return out; - } - - function exportData() { - els.dataBox.value = JSON.stringify(state, null, 2); - toast('Full JSON exported.'); - } - - function exportCompactData() { - const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state; - els.dataBox.value = JSON.stringify(payload); - toast('Compact JSON exported.'); - } - - function exportSnapshotData() { - if (!Phase2Sync?.makeSnapshot) { - exportData(); - return; - } - els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state)); - toast('Snapshot JSON exported.'); - } - - function exportAssetBundleData() { - if (!Phase2Sync?.makeAssetBundle) { - exportData(); - return; - } - els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id))); - toast('Asset bundle JSON exported.'); - } - - function importData() { - try { - const imported = JSON.parse(els.dataBox.value); - if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) { - toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.'); - return; - } - if (Phase2Sync?.isAssetBundle?.(imported)) { - const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset); - const byId = new Map(state.assets.map((asset) => [asset.id, asset])); - importedAssets.forEach((asset) => byId.set(asset.id, asset)); - state.assets = [...byId.values()]; - saveState(); - spriteCache.clear(); - renderLibrary(); - updateSelectedLabel(); - toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`); - return; - } - const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported; - state = normalizeState(expanded); - rebuildWorldIndex(); - hydrateAuthorUI(); - selectedAssetId = state.assets[0]?.id ?? null; - saveState(); - spriteCache.clear(); - hydrateRuntime(); - renderLibrary(); - updateSelectedLabel(); - toast('JSON imported.'); - } catch (error) { - toast('Invalid JSON.'); - console.error(error); - } - } - - function resetAll() { - if (!confirm('Reset all local assets and island objects?')) return; - localStorage.removeItem(STORAGE_KEY); - state = seedState(); - rebuildWorldIndex(); - hydrateAuthorUI(); - selectedAssetId = state.assets[0]?.id ?? null; - saveState(); - spriteCache.clear(); - hydrateRuntime(); - renderLibrary(); - updateSelectedLabel(); - toast('Local world reset.'); - } - - function recordSyncEvent(event) { - if (!event) return; - state.eventLog ||= []; - state.eventLog.push(event); - state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)); - state.sync ||= { lastEventId: null }; - state.sync.lastEventId = event.id; - } - - function cachePhase2State() { - if (!Phase2Sync?.cacheAssets) return; - Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); - if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) { - Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error)); - } - } - - function updateSyncStats() { - if (!els.syncStats) return; - const report = Phase2Sync?.compactSizeReport?.(state); - if (!report) { - els.syncStats.textContent = 'Phase 2 codec unavailable.'; - return; - } - const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet'; - const statsRect = getViewportWorldRect(64); - const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0; - els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`; - } - - function computeAssetContentHash(asset) { - const payload = [ - asset.category || '', - asset.subtype || '', - asset.size || '', - asset.pixels || '', - asset.faces?.right || '', - asset.faces?.left || '', - asset.meta?.depthPixels || '', - JSON.stringify(asset.meta?.lightPixels || []), - asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' - ].join('|'); - return `fnv1a:${fnv1a(payload)}`; - } - - function fnv1a(value) { - let hash = 0x811c9dc5; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return hash.toString(16).padStart(8, '0'); - } - - function clampInt(value, min, max, fallback = min) { - const n = Math.round(Number(value)); - return Number.isFinite(n) ? clamp(n, min, max) : fallback; - } - - function findAsset(id) { - return state.assets.find((asset) => asset.id === id) || null; - } - - function blankPixels(size) { - return Array(size * size).fill(null); - } - - function normalizePixels(pixels, size) { - const out = blankPixels(size); - if (typeof pixels === 'string') { - for (let i = 0; i < Math.min(out.length, pixels.length); i++) { - const ch = pixels[i]; - out[i] = ch === '.' ? null : (PALETTE_BY_CODE[ch] ? ch : nearestPaletteCode(ch)); - } - return out; - } - if (!Array.isArray(pixels)) return out; - for (let i = 0; i < Math.min(out.length, pixels.length); i++) { - const value = pixels[i]; - if (!value) out[i] = null; - else if (PALETTE_BY_CODE[value]) out[i] = value; - else out[i] = nearestPaletteCode(value); - } - return out; - } - - function encodePixels(pixels) { - return normalizePixels(pixels, Math.sqrt(pixels.length) || editorSize).map((value) => value || '.').join(''); - } - - function colorToHex(value) { - if (!value) return 'rgba(0,0,0,0)'; - return PALETTE_BY_CODE[value] || value; - } - - function nearestPaletteCode(color) { - if (!color || typeof color !== 'string') return null; - if (PALETTE_BY_CODE[color]) return color; - const rgb = parseHex(color); - if (!rgb) return selectedColorCode; - let best = PALETTE[0].code; - let bestDist = Infinity; - for (const entry of PALETTE) { - const p = parseHex(entry.color); - const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2; - if (dist < bestDist) { bestDist = dist; best = entry.code; } - } - return best; - } - - function readableTextColor(hex) { - const rgb = parseHex(hex); - if (!rgb) return '#243044'; - const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000; - return yiq > 140 ? '#243044' : '#fffdf5'; - } - - function parseHex(hex) { - if (typeof hex !== 'string' || !hex.startsWith('#')) return null; - const clean = hex.replace('#', ''); - const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean; - const value = parseInt(full, 16); - if (!Number.isFinite(value)) return null; - return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 }; - } - - - - function normalizeDepthPixels(input, size) { - const out = Array(size * size).fill(0); - if (typeof input === 'string') { - for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] === '1' ? 1 : 0; - return out; - } - if (!Array.isArray(input)) return out; - for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] ? 1 : 0; - return out; - } - - function encodeDepthPixels(input) { - return Array.from(input || []).map((v) => v ? '1' : '.').join(''); - } - - function resizeDepthPixels(source, oldSize, newSize) { - const normalized = normalizeDepthPixels(source, oldSize); - const out = Array(newSize * newSize).fill(0); - const min = Math.min(oldSize, newSize); - const xOffset = Math.floor((newSize - min) / 2); - const yOffset = Math.floor((newSize - min) / 2); - const oldOffset = Math.floor((oldSize - min) / 2); - for (let y = 0; y < min; y++) { - for (let x = 0; x < min; x++) { - out[(y + yOffset) * newSize + (x + xOffset)] = normalized[(y + oldOffset) * oldSize + (x + oldOffset)] || 0; - } - } - return out; - } - - 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; - } - - function applyDepthToColor(hex, depth) { - if (!depth) return hex; - const rgb = parseHex(hex); - if (!rgb) return hex; - const amt = 34; - const r = Math.min(255, rgb.r + amt); - const g = Math.min(255, rgb.g + amt); - const b = Math.min(255, rgb.b + amt); - return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`; - } - - function resizePixels(source, oldSize, newSize) { - const out = blankPixels(newSize); - const min = Math.min(oldSize, newSize); - const xOffset = Math.floor((newSize - min) / 2); - const yOffset = Math.floor((newSize - min) / 2); - const oldOffset = Math.floor((oldSize - min) / 2); - for (let y = 0; y < min; y++) { - for (let x = 0; x < min; x++) { - out[(y + yOffset) * newSize + (x + xOffset)] = source[(y + oldOffset) * oldSize + (x + oldOffset)] || null; - } - } - 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; - } - } - return out; - } - - function hasAnyPixel(pixels) { - return Array.isArray(pixels) && pixels.some(Boolean); - } - - - function fade(t) { - return t * t * t * (t * (t * 6 - 15) + 10); - } - - function hash2(ix, iy) { - let h = ix * 374761393 + iy * 668265263; - h = (h ^ (h >> 13)) * 1274126177; - return (h ^ (h >> 16)) >>> 0; - } - - function gradDot(ix, iy, x, y) { - const h = hash2(ix, iy) & 7; - const gx = [1, -1, 1, -1, 1, -1, 0, 0][h]; - const gy = [1, 1, -1, -1, 0, 0, 1, -1][h]; - return gx * (x - ix) + gy * (y - iy); - } - - function perlinNoise(x, y) { - const x0 = Math.floor(x), y0 = Math.floor(y); - const x1 = x0 + 1, y1 = y0 + 1; - const sx = fade(x - x0), sy = fade(y - y0); - const n00 = gradDot(x0, y0, x, y); - const n10 = gradDot(x1, y0, x, y); - const n01 = gradDot(x0, y1, x, y); - const n11 = gradDot(x1, y1, x, y); - const ix0 = lerp(n00, n10, sx); - const ix1 = lerp(n01, n11, sx); - return lerp(ix0, ix1, sy) * .5 + .5; - } - - function fractalPerlin(x, y, octaves = 4) { - let amp = .5; - let freq = 1; - let total = 0; - let norm = 0; - for (let i = 0; i < octaves; i++) { - total += perlinNoise(x * freq, y * freq) * amp; - norm += amp; - amp *= .5; - freq *= 2; - } - return total / norm; - } - - function smoothNoise(x, y) { - return Math.sin(x * .71 + y * .37) * .5 + Math.sin(x * .23 - y * .61) * .32 + Math.sin((x + y) * .17) * .18; - } - - function cap(value) { - return `${value}`.charAt(0).toUpperCase() + `${value}`.slice(1); - } - - - 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]; - const colors = [...neutrals]; - for (const light of lights) { - for (const hue of hues) colors.push(hslToHex(hue, 72, light)); - } - return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] })); - } - - function pseudoNoise(value) { - const x = Math.sin(value * 12.9898 + 78.233) * 43758.5453; - return x - Math.floor(x); - } - - function hslToHex(h, s, l) { - s /= 100; l /= 100; - const k = (n) => (n + h / 30) % 12; - const a = s * Math.min(l, 1 - l); - const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); - const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0'); - return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`; - } - - function hexToRgba(hex, alpha) { - const clean = hex.replace('#', ''); - const bigint = parseInt(clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean, 16); - const r = (bigint >> 16) & 255; - const g = (bigint >> 8) & 255; - const b = bigint & 255; - return `rgba(${r}, ${g}, ${b}, ${alpha})`; - } - - function toast(message) { - clearTimeout(toastTimer); - els.toast.textContent = message; - els.toast.hidden = false; - toastTimer = setTimeout(() => { els.toast.hidden = true; }, 1900); - } - - function px(pixels, size, x, y, color) { - if (x >= 0 && x < size && y >= 0 && y < size) pixels[y * size + x] = color; - } - function rect(pixels, size, x, y, w, h, color) { - for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) px(pixels, size, xx, yy, color); - } - function circle(pixels, size, cx, cy, r, color) { - for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) { - for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) { - if ((x - cx) ** 2 + (y - cy) ** 2 <= r ** 2) px(pixels, size, x, y, color); - } - } - } - - function drawHouse() { - const s = 16, p = blankPixels(s); - rect(p, s, 4, 8, 8, 6, '#c57955'); - rect(p, s, 6, 10, 3, 4, '#423044'); - rect(p, s, 10, 9, 2, 2, '#ffdd7d'); - rect(p, s, 4, 7, 8, 1, '#9b5163'); - for (let y = 3; y <= 7; y++) { - for (let x = 3 + Math.abs(6 - y); x <= 12 - Math.abs(6 - y); x++) px(p, s, x, y, '#81495c'); - } - rect(p, s, 2, 8, 12, 1, '#654154'); - return p; - } - - function drawTree() { - const s = 16, p = blankPixels(s); - rect(p, s, 7, 9, 2, 5, '#805032'); - circle(p, s, 8, 5, 4, '#58b869'); - circle(p, s, 5, 7, 3, '#4aa95d'); - circle(p, s, 11, 7, 3, '#4aa95d'); - circle(p, s, 8, 8, 4, '#63ca75'); - px(p, s, 6, 5, '#8bef8f'); - px(p, s, 10, 4, '#8bef8f'); - return p; - } - - function drawLantern() { - const s = 16, p = blankPixels(s); - rect(p, s, 7, 5, 2, 8, '#514b60'); - rect(p, s, 5, 4, 6, 1, '#514b60'); - rect(p, s, 6, 5, 4, 4, '#b8e8ff'); - rect(p, s, 3, 13, 10, 1, '#86623f'); - px(p, s, 5, 9, '#514b60'); px(p, s, 10, 9, '#514b60'); - return p; - } - - function drawHumanRight() { - const s = 16, p = blankPixels(s); - rect(p, s, 7, 3, 3, 3, '#e6b887'); - rect(p, s, 6, 6, 5, 5, '#5d8bea'); - px(p, s, 10, 4, '#273046'); - rect(p, s, 5, 7, 1, 3, '#e6b887'); rect(p, s, 11, 7, 1, 3, '#e6b887'); - rect(p, s, 6, 11, 2, 3, '#31354f'); rect(p, s, 9, 11, 2, 3, '#31354f'); - rect(p, s, 6, 2, 5, 1, '#3a2b35'); - return p; - } - function drawHumanLeft() { return mirrorPixels(drawHumanRight(), 16); } - - function drawDogRight() { - const s = 16, p = blankPixels(s); - rect(p, s, 4, 8, 7, 4, '#b77a46'); - rect(p, s, 10, 7, 3, 3, '#c98952'); - px(p, s, 12, 8, '#2f1e17'); - rect(p, s, 5, 12, 1, 2, '#754b31'); rect(p, s, 9, 12, 1, 2, '#754b31'); - px(p, s, 3, 8, '#b77a46'); px(p, s, 2, 7, '#b77a46'); - return p; - } - function drawDogLeft() { return mirrorPixels(drawDogRight(), 16); } - - function drawFishRight() { - const s = 16, p = blankPixels(s); - rect(p, s, 5, 7, 6, 3, '#4bd7ff'); - px(p, s, 11, 8, '#e7fbff'); px(p, s, 4, 7, '#258ac0'); px(p, s, 3, 6, '#258ac0'); px(p, s, 3, 10, '#258ac0'); - px(p, s, 6, 6, '#90edff'); px(p, s, 8, 10, '#90edff'); - return p; - } - function drawFishLeft() { return mirrorPixels(drawFishRight(), 16); } - - function drawBirdRight() { - const s = 16, p = blankPixels(s); - rect(p, s, 7, 6, 3, 3, '#f4d45f'); - px(p, s, 10, 7, '#f09062'); - rect(p, s, 4, 6, 3, 1, '#6da9ef'); - rect(p, s, 10, 5, 3, 1, '#6da9ef'); - px(p, s, 8, 5, '#242434'); - return p; - } - function drawBirdLeft() { return mirrorPixels(drawBirdRight(), 16); } - - bootstrap(); -})(); diff --git a/index.html b/index.html index eefd087..ba16f62 100644 --- a/index.html +++ b/index.html @@ -60,6 +60,7 @@ @@ -67,7 +68,7 @@
-
+
+
+
Settings
+

Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.

+
+ + + + +
+
+
+
+
Local save
@@ -198,21 +215,6 @@
- -
-
Visual settings
-

Turn major visual systems on or off. Server decides the public 250 exhibition slots; this local cap only filters your view.

-
- - - - -
-
-
-
Phase 5 guardrails

Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.

@@ -270,7 +272,11 @@
- + + + + + diff --git a/index.html.bak b/index.html.bak deleted file mode 100644 index 7a6c57e..0000000 --- a/index.html.bak +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - Pixel Island Summoner - Local Prototype - - - -
- - - - -
-
- -
Local prototype / Draw pixels, summon them to islands.
-
- -
-
Day
-
-
10 min = 1 day
-
-
- - - -
-
-
-

Pixel Studio

-

Make static scenery or moving island life.

-
- -
- - - -
-
-
-
- - -
- - -
- -
- -
- -
-
-
-
- -
- - - - - - - - - -
-
- - - - - -
-
- - - - - -
-
- - - -
-
- - - - -
- -
Palette color is used for pixels, lights, and depth marks. Shortcuts: B/E/F/I/L/R/S, Ctrl/Cmd+Z, arrows move a selection.
-
- -
-
Finish
-
- - - -
-
-
-
- -
-
-
-
Library
- -
-

Click an asset to select it and jump to it on the map. Copy Edit creates a new derivative.

- -
-
-
- -
-
-
Local save
-

Saved to this browser only. Export JSON if you want to move or share a local world.

-
- - - - - - -
-
- -
-
-
-
- - - -
- - - - - - diff --git a/js/core-utils.js b/js/core-utils.js new file mode 100644 index 0000000..d833b1b --- /dev/null +++ b/js/core-utils.js @@ -0,0 +1,33 @@ +(function () { + 'use strict'; + + const root = window.PixelIslandModules ||= {}; + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function mod(n, m) { + return ((n % m) + m) % m; + } + + function lerp(a, b, t) { + return a + (b - a) * t; + } + + function fnv1a(value) { + let hash = 0x811c9dc5; + const text = String(value); + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); + } + + function uid() { + return Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5); + } + + root.CoreUtils = { clamp, mod, lerp, fnv1a, uid }; +})(); diff --git a/js/lighting.js b/js/lighting.js index a22bbd6..83197f0 100644 --- a/js/lighting.js +++ b/js/lighting.js @@ -2,67 +2,115 @@ 'use strict'; const root = window.PixelIslandModules ||= {}; - const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); - const lerp = (a, b, t) => a + (b - a) * t; - const mod = (n, m) => ((n % m) + m) % m; + const utils = root.CoreUtils || {}; + const clamp = utils.clamp || ((value, min, max) => Math.max(min, Math.min(max, value))); + const lerp = utils.lerp || ((a, b, t) => a + (b - a) * t); + const mod = utils.mod || ((n, m) => ((n % m) + m) % m); - function getCelestialLightForMinute(minute) { - const isNight = minute >= 6; - const local = isNight ? (minute - 6) / 4 : minute / 6; - const t = clamp(local, 0, 1); - const eased = t * t * (3 - 2 * t); - const elevation = Math.max(.08, Math.sin(t * Math.PI)); - const sourceX = isNight ? lerp(-1.05, 1.05, eased) : lerp(1.18, -1.18, eased); - const sourceY = isNight ? -.46 - elevation * .24 : -.58 - elevation * .34; - return { - x: sourceX, - y: sourceY, - elevation, - isNight, - shadeAlpha: isNight ? .18 : lerp(.20, .08, elevation) - }; + function smooth(t) { + const n = clamp(t, 0, 1); + return n * n * (3 - 2 * n); } function getShadowForMinute(minute) { - const light = getCelestialLightForMinute(minute); - const length = lerp(light.isNight ? 1.35 : 1.9, light.isNight ? .85 : .52, light.elevation); - return { - x: -light.x * 6.5 * length, - y: Math.min(-1.8, light.y * 4.2 * length), - length, - alpha: light.isNight ? .085 : lerp(.27, .12, light.elevation) - }; + const m = clamp(Number(minute) || 0, 0, 10); + let dirX = -0.9; + let length = 1.2; + let alpha = 0; + let scaleY = 0.26; + + if (m < 0.65) { + const t = smooth(m / 0.65); + dirX = lerp(0.42, 0.18, t); + length = lerp(1.05, 0.92, t); + alpha = lerp(0.12, 0.0, t); + scaleY = lerp(0.22, 0.18, t); + } else if (m < 1.35) { + const t = smooth((m - 0.65) / 0.70); + dirX = lerp(-1.05, -0.82, t); + length = lerp(1.38, 1.12, t); + alpha = lerp(0.0, 0.27, t); + scaleY = lerp(0.22, 0.27, t); + } else if (m < 5.65) { + const t = smooth((m - 1.35) / 4.30); + const elevation = Math.sin(t * Math.PI); + dirX = lerp(-0.82, 0.98, t); + length = lerp(1.12, 1.34, t) - elevation * 0.48; + alpha = lerp(0.27, 0.20, elevation); + scaleY = 0.24 + 0.06 * (1 - elevation); + } else if (m < 6.40) { + const t = smooth((m - 5.65) / 0.75); + dirX = lerp(0.98, 1.08, t); + length = lerp(1.34, 1.56, t); + alpha = lerp(0.20, 0.0, t); + scaleY = lerp(0.28, 0.20, t); + } else if (m < 7.25) { + const t = smooth((m - 6.40) / 0.85); + dirX = lerp(0.20, -0.36, t); + length = lerp(0.92, 1.08, t); + alpha = lerp(0.0, 0.12, t); + scaleY = lerp(0.18, 0.23, t); + } else if (m < 9.20) { + const t = smooth((m - 7.25) / 1.95); + dirX = lerp(-0.36, -0.58, t); + length = lerp(1.08, 1.18, t); + alpha = 0.12; + scaleY = 0.23; + } else { + const t = smooth((m - 9.20) / 0.80); + dirX = lerp(-0.58, 0.42, t); + length = lerp(1.18, 1.05, t); + alpha = lerp(0.12, 0.0, t); + scaleY = lerp(0.23, 0.18, t); + } + + return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), scaleY, alpha }; } - function getPhase(dayMs, now = Date.now()) { - const t = mod(now, dayMs); + function getPhase(options) { + const { dayMs, now, dayNightEnabled, mixHex } = options; + if (dayNightEnabled === false) { + return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; + } + const t = mod(now || Date.now(), dayMs); const minute = t / 60000; const stops = [ - { at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] }, - { at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] }, - { at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] }, - { at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] } + { at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }, + { at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] }, + { at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] }, + { at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, + { at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, + { at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] }, + { at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] }, + { at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] }, + { at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] }, + { at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] } ]; let a = stops[0], b = stops[1]; for (let i = 0; i < stops.length - 1; i++) { - if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; } - if (minute >= 6) { a = stops[3]; b = stops[4]; } + if (minute >= stops[i].at && minute < stops[i + 1].at) { + a = stops[i]; + b = stops[i + 1]; + break; + } } - const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); - const eased = localT * localT * (3 - 2 * localT); - const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); - const label = minute < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night'; + const eased = smooth((minute - a.at) / Math.max(0.0001, b.at - a.at)); + const tint = a.tint.map((value, i) => lerp(value, b.tint[i], eased)); + const overlay = a.overlay.map((value, i) => lerp(value, b.overlay[i], eased)); + const darkness = lerp(a.darkness, b.darkness, eased); + const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day')); return { - key: label.toLowerCase(), - label, + key: dominantKey, + label: dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day', progress: t / dayMs, - darkness: lerp(a.darkness, b.darkness, eased), + sky: mixHex(a.sky, b.sky, eased), + darkness, + darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`, tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, - light: getCelestialLightForMinute(minute), + tintAlpha: tint[3], shadow: getShadowForMinute(minute) }; } - root.Lighting = { getPhase, getShadowForMinute, getCelestialLightForMinute }; + root.Lighting = { getPhase, getShadowForMinute }; })(); diff --git a/js/module-loader.js b/js/module-loader.js new file mode 100644 index 0000000..cdabc3c --- /dev/null +++ b/js/module-loader.js @@ -0,0 +1,22 @@ +(function () { + 'use strict'; + + const root = window.PixelIslandModules ||= {}; + const pending = new Map(); + + function loadScript(src) { + if (pending.has(src)) return pending.get(src); + const promise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.defer = true; + script.onload = () => resolve(script); + script.onerror = () => reject(new Error(`Could not load ${src}`)); + document.head.appendChild(script); + }); + pending.set(src, promise); + return promise; + } + + root.ModuleLoader = { loadScript }; +})(); diff --git a/js/phase2-sync.js b/js/phase2-sync.js index 0300bb1..7e36296 100644 --- a/js/phase2-sync.js +++ b/js/phase2-sync.js @@ -595,6 +595,10 @@ packAsset, unpackAsset, unpackAssets, + packPlacement, + unpackPlacement, + packDynamic, + unpackDynamic, compactState, expandState, compactSizeReport, diff --git a/js/phase2-sync.js.bak b/js/phase2-sync.js.bak deleted file mode 100644 index 060efc8..0000000 --- a/js/phase2-sync.js.bak +++ /dev/null @@ -1,402 +0,0 @@ -(function () { - 'use strict'; - - const root = window.PixelIslandModules ||= {}; - const FORMAT = 'pixel-island-phase2-compact-v1'; - const SNAPSHOT_FORMAT = 'pixel-island-phase2-snapshot-v1'; - const ASSET_BUNDLE_FORMAT = 'pixel-island-phase2-asset-bundle-v1'; - const EVENT_LOG_LIMIT = 300; - const DB_NAME = 'pixel-island-phase2-cache'; - const DB_VERSION = 1; - const ASSET_STORE = 'assets'; - const SNAPSHOT_STORE = 'snapshots'; - - function isCompactState(value) { - return Boolean(value && value.format === FORMAT && Array.isArray(value.assets)); - } - - function isAssetBundle(value) { - return Boolean(value && value.format === ASSET_BUNDLE_FORMAT && Array.isArray(value.assets)); - } - - function rleEncode(input) { - const text = String(input || ''); - if (!text) return ''; - let out = ''; - let last = text[0]; - let count = 1; - for (let i = 1; i < text.length; i++) { - const ch = text[i]; - if (ch === last) count++; - else { - out += `${count}:${last}`; - last = ch; - count = 1; - } - } - out += `${count}:${last}`; - return out; - } - - function rleDecode(input) { - const text = String(input || ''); - if (!text) return ''; - let out = ''; - let i = 0; - while (i < text.length) { - let digits = ''; - while (i < text.length && text[i] >= '0' && text[i] <= '9') digits += text[i++]; - if (text[i] !== ':') break; - i++; - const ch = text[i++] || ''; - const count = Math.max(0, Number(digits) || 0); - out += ch.repeat(count); - } - return out; - } - - function normalizeEncodedPlane(value, size, emptyChar = '.') { - const total = Math.max(1, Number(size) || 1) ** 2; - const source = typeof value === 'string' ? value : Array.isArray(value) ? value.map((v) => v || emptyChar).join('') : ''; - return (source + emptyChar.repeat(total)).slice(0, total); - } - - function cropPlane(encoded, size, emptyChar = '.') { - const text = normalizeEncodedPlane(encoded, size, emptyChar); - let minX = size; - let minY = size; - let maxX = -1; - let maxY = -1; - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - if (text[y * size + x] !== emptyChar) { - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - } - } - if (maxX < 0) return { b: null, e: 'raw', v: '' }; - const w = maxX - minX + 1; - const h = maxY - minY + 1; - let cropped = ''; - for (let y = minY; y <= maxY; y++) { - cropped += text.slice(y * size + minX, y * size + minX + w); - } - const rle = rleEncode(cropped); - return rle.length < cropped.length ? { b: [minX, minY, w, h], e: 'rle', v: rle } : { b: [minX, minY, w, h], e: 'raw', v: cropped }; - } - - function expandPlane(packed, size, emptyChar = '.') { - const total = Math.max(1, Number(size) || 1) ** 2; - const out = Array(total).fill(emptyChar); - if (!packed || !packed.b) return out.join(''); - const [x0, y0, w, h] = packed.b.map((v) => Math.max(0, Number(v) || 0)); - const value = packed.e === 'rle' ? rleDecode(packed.v) : String(packed.v || ''); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const src = y * w + x; - const dx = x0 + x; - const dy = y0 + y; - if (dx >= 0 && dy >= 0 && dx < size && dy < size && src < value.length) { - out[dy * size + dx] = value[src] || emptyChar; - } - } - } - return out.join(''); - } - - function packAsset(asset) { - const size = Math.max(1, Number(asset.size) || 16); - const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; - const right = normalizeEncodedPlane(asset.faces?.right || asset.pixels || '', size, '.'); - const depth = asset.meta?.depthPixels ? normalizeEncodedPlane(asset.meta.depthPixels, size, '.') : ''; - const lights = Array.isArray(asset.meta?.lightPixels) - ? asset.meta.lightPixels.map((p) => [Number(p.x) || 0, Number(p.y) || 0, p.c || asset.meta?.lightColor || '']).filter((p) => p[2]) - : []; - const meta = {}; - if (depth && /1/.test(depth)) meta.d = cropPlane(depth, size, '.'); - if (lights.length) meta.l = lights; - if (asset.meta?.lightColor) meta.lc = asset.meta.lightColor; - if (asset.meta?.door) meta.dr = [Number(asset.meta.door.x) || 0, Number(asset.meta.door.y) || 0]; - - return { - id: asset.id, - h: asset.contentHash || asset.hash || null, - n: asset.name || 'Untitled', - c: category, - t: asset.subtype || (category === 'dynamic' ? 'human' : 'other'), - s: size, - p: cropPlane(right, size, '.'), - f: category === 'dynamic' ? { l: 'mirror' } : null, - pa: asset.parentAssetId || null, - oa: asset.originalAssetId || null, - ca: asset.createdAt || Date.now(), - ua: asset.updatedAt || asset.createdAt || Date.now(), - au: asset.author || 'Local Artist', - m: Object.keys(meta).length ? meta : null - }; - } - - function unpackAsset(packed) { - if (!packed || !packed.id) return null; - const size = Math.max(1, Number(packed.s || packed.size) || 16); - const category = packed.c === 'dynamic' || packed.category === 'dynamic' ? 'dynamic' : 'static'; - const pixels = expandPlane(packed.p, size, '.'); - const metaPacked = packed.m || {}; - const lightPixels = Array.isArray(metaPacked.l) - ? metaPacked.l.map((p) => ({ x: Number(p[0]) || 0, y: Number(p[1]) || 0, c: p[2] || metaPacked.lc || 'a' })) - : []; - const meta = { - hasLight: lightPixels.length > 0, - lightPixels, - lightColor: lightPixels.length > 0 ? (metaPacked.lc || lightPixels[0]?.c || null) : null, - depthPixels: metaPacked.d ? expandPlane(metaPacked.d, size, '.') : null, - door: Array.isArray(metaPacked.dr) ? { x: Number(metaPacked.dr[0]) || 0, y: Number(metaPacked.dr[1]) || 0 } : null - }; - return { - id: packed.id, - name: packed.n || 'Untitled', - category, - subtype: packed.t || (category === 'dynamic' ? 'human' : 'other'), - size, - pixels, - faces: category === 'dynamic' ? { right: pixels, left: 'mirror' } : null, - parentAssetId: packed.pa || null, - originalAssetId: packed.oa || null, - createdAt: packed.ca || Date.now(), - updatedAt: packed.ua || packed.ca || Date.now(), - author: packed.au || 'Local Artist', - meta, - contentHash: packed.h || null - }; - } - - function packPlacement(item) { - return [item.id, item.assetId, Number(item.x) || 0, Number(item.y) || 0, item.placedAt || Date.now(), Number(item.version) || 1]; - } - - function unpackPlacement(row) { - if (!Array.isArray(row)) return row; - return { id: row[0], assetId: row[1], x: row[2], y: row[3], placedAt: row[4], version: row[5] || 1 }; - } - - function packDynamic(item) { - return [item.id, item.assetId, Number(item.homeX) || 0, Number(item.homeY) || 0, item.createdAt || Date.now(), Number(item.version) || 1]; - } - - function unpackDynamic(row) { - if (!Array.isArray(row)) return row; - return { id: row[0], assetId: row[1], homeX: row[2], homeY: row[3], createdAt: row[4], version: row[5] || 1 }; - } - - function compactState(state) { - return { - schema: 3, - format: FORMAT, - authorName: state.authorName || 'Local Artist', - assets: Array.isArray(state.assets) ? state.assets.map(packAsset) : [], - placed: Array.isArray(state.placed) ? state.placed.map(packPlacement) : [], - dynamicSummons: Array.isArray(state.dynamicSummons) ? state.dynamicSummons.map(packDynamic) : [], - objectVotes: state.objectVotes || {}, - assetVotes: state.assetVotes || {}, - hiddenAssets: state.hiddenAssets || {}, - hiddenObjects: state.hiddenObjects || {}, - eventLog: Array.isArray(state.eventLog) ? state.eventLog.slice(-EVENT_LOG_LIMIT) : [], - sync: state.sync || { lastEventId: null } - }; - } - - function expandState(input) { - if (!isCompactState(input)) return input; - return { - schema: 3, - authorName: input.authorName || 'Local Artist', - assets: input.assets.map(unpackAsset).filter(Boolean), - placed: (input.placed || []).map(unpackPlacement), - dynamicSummons: (input.dynamicSummons || []).map(unpackDynamic), - objectVotes: input.objectVotes || {}, - assetVotes: input.assetVotes || {}, - hiddenAssets: input.hiddenAssets || {}, - hiddenObjects: input.hiddenObjects || {}, - eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-EVENT_LOG_LIMIT) : [], - sync: input.sync || { lastEventId: null } - }; - } - - function compactSizeReport(state) { - const full = JSON.stringify({ ...state, schema: 3 }); - const compact = JSON.stringify(compactState(state)); - return { - fullBytes: full.length, - compactBytes: compact.length, - savedBytes: Math.max(0, full.length - compact.length), - savedPercent: full.length ? Math.round((1 - compact.length / full.length) * 1000) / 10 : 0, - assets: state.assets?.length || 0, - objects: (state.placed?.length || 0) + (state.dynamicSummons?.length || 0) - }; - } - - function assetManifest(state) { - return (state.assets || []).map((asset) => ({ id: asset.id, h: asset.contentHash || null, s: asset.size, c: asset.category, t: asset.subtype })); - } - - function makeSnapshot(state, worldId = 'local-main') { - return { - schema: 3, - format: SNAPSHOT_FORMAT, - worldId, - createdAt: Date.now(), - manifest: assetManifest(state), - placed: (state.placed || []).map(packPlacement), - dynamicSummons: (state.dynamicSummons || []).map(packDynamic), - hiddenObjects: state.hiddenObjects || {} - }; - } - - function makeAssetBundle(state, assetIds) { - const wanted = new Set(assetIds || []); - const assets = (state.assets || []).filter((asset) => !wanted.size || wanted.has(asset.id)).map(packAsset); - return { schema: 3, format: ASSET_BUNDLE_FORMAT, createdAt: Date.now(), assets }; - } - - function unpackAssetBundle(bundle) { - if (!isAssetBundle(bundle)) return []; - return bundle.assets.map(unpackAsset).filter(Boolean); - } - - function findMissingAssetIds(snapshot, knownAssetIds) { - const known = new Set(knownAssetIds || []); - return (snapshot?.manifest || []).map((asset) => asset.id).filter((id) => id && !known.has(id)); - } - - function makeEvent(type, payload = {}) { - return { id: `ev_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, at: Date.now(), ...payload }; - } - - function createAssetUpsertEvent(asset) { - return makeEvent('asset.upsert', { asset: packAsset(asset) }); - } - - function createObjectUpsertEvent(kind, object) { - return makeEvent('object.upsert', { kind, object: kind === 'dynamic' ? packDynamic(object) : packPlacement(object) }); - } - - function createObjectDeleteEvent(kind, objectId) { - return makeEvent('object.delete', { kind, objectId }); - } - - function applyEvent(state, event) { - if (!state || !event) return state; - if (event.type === 'asset.upsert' && event.asset) { - const asset = unpackAsset(event.asset); - if (!asset) return state; - const index = (state.assets || []).findIndex((a) => a.id === asset.id); - if (index >= 0) state.assets[index] = asset; - else (state.assets ||= []).unshift(asset); - } - if (event.type === 'object.upsert') { - if (event.kind === 'dynamic') { - const object = unpackDynamic(event.object); - const list = state.dynamicSummons ||= []; - const index = list.findIndex((item) => item.id === object.id); - if (index >= 0) list[index] = object; - else list.push(object); - } else { - const object = unpackPlacement(event.object); - const list = state.placed ||= []; - const index = list.findIndex((item) => item.id === object.id); - if (index >= 0) list[index] = object; - else list.push(object); - } - } - if (event.type === 'object.delete') { - const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed'; - state[key] = (state[key] || []).filter((item) => item.id !== event.objectId); - } - return state; - } - - function openDb() { - if (!('indexedDB' in window)) return Promise.reject(new Error('IndexedDB is not available.')); - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(ASSET_STORE)) db.createObjectStore(ASSET_STORE, { keyPath: 'id' }); - if (!db.objectStoreNames.contains(SNAPSHOT_STORE)) db.createObjectStore(SNAPSHOT_STORE, { keyPath: 'worldId' }); - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - } - - async function cacheAssets(assets) { - if (!Array.isArray(assets) || !assets.length) return; - const db = await openDb(); - await new Promise((resolve, reject) => { - const tx = db.transaction(ASSET_STORE, 'readwrite'); - for (const asset of assets) tx.objectStore(ASSET_STORE).put(packAsset(asset)); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - db.close(); - } - - async function readCachedAssets(assetIds) { - const ids = Array.from(assetIds || []); - if (!ids.length) return []; - const db = await openDb(); - const rows = await Promise.all(ids.map((id) => new Promise((resolve) => { - const request = db.transaction(ASSET_STORE, 'readonly').objectStore(ASSET_STORE).get(id); - request.onsuccess = () => resolve(request.result || null); - request.onerror = () => resolve(null); - }))); - db.close(); - return rows.filter(Boolean).map(unpackAsset).filter(Boolean); - } - - async function cacheSnapshot(snapshot) { - if (!snapshot?.worldId) return; - const db = await openDb(); - await new Promise((resolve, reject) => { - const tx = db.transaction(SNAPSHOT_STORE, 'readwrite'); - tx.objectStore(SNAPSHOT_STORE).put(snapshot); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - db.close(); - } - - root.Phase2Sync = { - FORMAT, - SNAPSHOT_FORMAT, - ASSET_BUNDLE_FORMAT, - EVENT_LOG_LIMIT, - isCompactState, - isAssetBundle, - rleEncode, - rleDecode, - cropPlane, - expandPlane, - packAsset, - unpackAsset, - compactState, - expandState, - compactSizeReport, - assetManifest, - makeSnapshot, - makeAssetBundle, - unpackAssetBundle, - findMissingAssetIds, - makeEvent, - createAssetUpsertEvent, - createObjectUpsertEvent, - createObjectDeleteEvent, - applyEvent, - cacheAssets, - readCachedAssets, - cacheSnapshot - }; -})(); diff --git a/js/render-pipeline.js b/js/render-pipeline.js deleted file mode 100644 index 0f202cf..0000000 --- a/js/render-pipeline.js +++ /dev/null @@ -1,11 +0,0 @@ -(function () { - 'use strict'; - - const root = window.PixelIslandModules ||= {}; - - root.RenderPipeline = { - run(stages, context) { - for (const stage of stages) stage(context); - } - }; -})(); diff --git a/js/rotation-policy.js b/js/rotation-policy.js new file mode 100644 index 0000000..56dc3ad --- /dev/null +++ b/js/rotation-policy.js @@ -0,0 +1,76 @@ +(function () { + 'use strict'; + + const root = window.PixelIslandModules ||= {}; + const utils = root.CoreUtils || {}; + const fnv1a = utils.fnv1a || ((value) => { + let hash = 0x811c9dc5; + for (const ch of String(value)) { + hash ^= ch.charCodeAt(0); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); + }); + + const DEFAULT_CONFIG = { + defaultDisplayLimit: 250, + newArrivalSlots: 150, + revivalSlots: 100, + publishLimitFirstDay: 5, + publishLimitTrusted: 10, + upvoteDelaySlots: 20, + downvoteAdvanceSlots: 25, + upvoteRankCap: 50 + }; + + function objectPublicAt(object) { + return Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now()); + } + + function entry(kind, object, baseIndex, votes, config = DEFAULT_CONFIG) { + const rawUp = Number(votes?.up) || 0; + const up = Math.min(rawUp, config.upvoteRankCap); + const down = Number(votes?.down) || 0; + return { + kind, + object, + id: object?.id, + assetId: object?.assetId, + publicAt: objectPublicAt(object), + baseIndex, + up, + rawUp, + down, + effectiveSlot: baseIndex + up * config.upvoteDelaySlots - down * config.downvoteAdvanceSlots + }; + } + + function seededScore(id, salt, rotationAt = Date.now()) { + const day = Math.floor((rotationAt || Date.now()) / (24 * 60 * 60 * 1000)); + return parseInt(fnv1a(`${id}|${salt}|${day}`).slice(0, 8), 16) / 0xffffffff; + } + + function buckets(entries, localLimit, config = DEFAULT_CONFIG, rotationAt = Date.now()) { + const newCap = Math.min(config.newArrivalSlots, localLimit); + const revivalCap = Math.max(0, Math.min(config.revivalSlots, localLimit - newCap)); + const newest = entries + .slice() + .sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id))) + .slice(0, newCap); + const newestIds = new Set(newest.map((item) => item.id)); + const revival = entries + .filter((item) => !newestIds.has(item.id)) + .sort((a, b) => seededScore(b.id, 'revival', rotationAt) - seededScore(a.id, 'revival', rotationAt) || b.up - a.up || String(b.id).localeCompare(String(a.id))) + .slice(0, revivalCap); + return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((item) => item.id)) }; + } + + function publishLimit(account, now = Date.now(), config = DEFAULT_CONFIG) { + if (!account?.createdAt) return 0; + return now - Number(account.createdAt) < 24 * 60 * 60 * 1000 + ? config.publishLimitFirstDay + : config.publishLimitTrusted; + } + + root.RotationPolicy = { DEFAULT_CONFIG, objectPublicAt, entry, buckets, seededScore, publishLimit }; +})(); diff --git a/js/state-index.js b/js/state-index.js new file mode 100644 index 0000000..5b3e1b8 --- /dev/null +++ b/js/state-index.js @@ -0,0 +1,103 @@ +(function () { + 'use strict'; + + const root = window.PixelIslandModules ||= {}; + + function tileKey(x, y) { + return `${Math.round(x)},${Math.round(y)}`; + } + + function pushBucket(map, key, item) { + const bucket = map.get(key); + if (bucket) bucket.push(item); + else map.set(key, [item]); + } + + function build(state) { + const index = { + assetById: new Map(), + staticById: new Map(), + dynamicById: new Map(), + objectsById: new Map(), + placedByTile: new Map(), + dynamicByHomeTile: new Map(), + objectIdsByAssetId: new Map() + }; + + for (const asset of state.assets || []) { + if (asset?.id) index.assetById.set(asset.id, asset); + } + + for (const placed of state.placed || []) { + if (!placed?.id) continue; + index.staticById.set(placed.id, placed); + index.objectsById.set(placed.id, { kind: 'static', object: placed }); + pushBucket(index.placedByTile, tileKey(placed.x, placed.y), placed); + pushBucket(index.objectIdsByAssetId, placed.assetId, placed.id); + } + + for (const summon of state.dynamicSummons || []) { + if (!summon?.id) continue; + index.dynamicById.set(summon.id, summon); + index.objectsById.set(summon.id, { kind: 'dynamic', object: summon }); + pushBucket(index.dynamicByHomeTile, tileKey(summon.homeX, summon.homeY), summon); + pushBucket(index.objectIdsByAssetId, summon.assetId, summon.id); + } + + return index; + } + + function reduce(state, event, handlers = {}) { + if (!state || !event) return state; + switch (event.type) { + case 'asset.upsert': { + const asset = handlers.unpackAsset ? handlers.unpackAsset(event.asset) : event.asset; + if (!asset?.id) return state; + const list = state.assets ||= []; + const index = list.findIndex((item) => item.id === asset.id); + if (index >= 0) list[index] = asset; + else list.unshift(asset); + break; + } + case 'asset.delete': { + const assetId = event.assetId; + if (!assetId) return state; + const removed = new Set(); + for (const item of state.placed || []) if (item.assetId === assetId) removed.add(item.id); + for (const item of state.dynamicSummons || []) if (item.assetId === assetId) removed.add(item.id); + state.assets = (state.assets || []).filter((asset) => asset.id !== assetId); + state.placed = (state.placed || []).filter((item) => item.assetId !== assetId); + state.dynamicSummons = (state.dynamicSummons || []).filter((item) => item.assetId !== assetId); + delete state.assetVotes?.[assetId]; + delete state.hiddenAssets?.[assetId]; + for (const id of removed) { + delete state.objectVotes?.[id]; + delete state.hiddenObjects?.[id]; + } + state.moderationReports = (state.moderationReports || []).filter((report) => !removed.has(report.objectId)); + break; + } + case 'object.upsert': { + const object = handlers.unpackObject ? handlers.unpackObject(event.kind, event.object) : event.object; + if (!object?.id) return state; + const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed'; + const list = state[key] ||= []; + const index = list.findIndex((item) => item.id === object.id); + if (index >= 0) list[index] = object; + else list.push(object); + break; + } + case 'object.delete': { + const key = event.kind === 'dynamic' ? 'dynamicSummons' : 'placed'; + state[key] = (state[key] || []).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); + break; + } + } + return state; + } + + root.StateIndex = { build, reduce, tileKey }; +})(); diff --git a/js/vector.js b/js/vector.js deleted file mode 100644 index cf6f898..0000000 --- a/js/vector.js +++ /dev/null @@ -1,24 +0,0 @@ -(function () { - 'use strict'; - - const root = window.PixelIslandModules ||= {}; - - root.Vec2 = { - add(a, b) { - return { x: a.x + b.x, y: a.y + b.y }; - }, - sub(a, b) { - return { x: a.x - b.x, y: a.y - b.y }; - }, - scale(v, amount) { - return { x: v.x * amount, y: v.y * amount }; - }, - length(v) { - return Math.hypot(v.x, v.y); - }, - normalize(v) { - const length = Math.hypot(v.x, v.y) || 1; - return { x: v.x / length, y: v.y / length }; - } - }; -})(); diff --git a/server/__pycache__/compression_lab.cpython-313.pyc b/server/__pycache__/compression_lab.cpython-313.pyc deleted file mode 100644 index 9d230cc2485e8a9929eb507d1056a90a38d5840f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2515 zcma)7O>7)R7OtM|>FJ*tn{g5+aRQWWSTH!_wUPjKVwQwpl9F=RomCABAx@=?+0q+1)X8ObEVs8nJ$Pc5E)Tfc_aP;|Lu0j~aVZ;KzOc^eMfYC5? z10NTO@3;<$XoyPz!fv*t2VzXZkVg2tHGZ*R7*ioB(_sVS%0e^?iXmlJDH~|9 zWqV|%P+7oqz7nvAI`*}Ic~0R<82E-UIqQbl2>`|qB3zs$;0dme znx#0X2oU1o*BbKI@Ejm}6oXT)A4bIUs8cXTAp>yW8pJlxh!1un${OzWgTJ&y*>Idd zQf#CKA9*$gAJ{=85k5OPc?o-DfwF?JDXywd;wJGSa3b6YFTz^6M55W^&V|Orgjq&N z+Ws*m3Tk?HZvxgoAI)O$(G;41>;L(%q-iygS*al?NjRq`k|s@x%9p0&Vi!9T+2(%2 z^`I$fqAgi9=|sNCC!8f)%8-xAVQm~w|(w1svhUAHTO?kpu@~$QMcUjUb z{WmQsmNC6QL73V_IF_=^7P*ojgFM@mPajA$C%O`@Ng*D^%2{ngsTS?_7VX2y*JpmH zUSFL`??Gn)rwWqF{B&>P$yCPL*N&{tDb3ms^uQ~BKLS;59R$AXsM^H2fDtmmR{2oh z;fq`s9m6iU9u@B-TBy*)bTuj+c?Omm^^1Yy`sLA0@hbXKJWKts%BW4kqU&4rdWym@hPL5x^7-rGcSBzuA46}o%v4h{6JnAec!BUk8J1%KImO>-tUX~{M$$0j^4Ze z&+E4*!RG!G^*twR+Q^39BEJXnqxC(7(Go;GXm8>9s$+SnoMo+k5Uy?fiYS?Z(wNPpq4VR=StYeBIe~)3~AB*K*5ymuHss zmHf)F+w#g2x6j_4{P-{TqFVpS)$v-}OaIm`d@rN@hrTnj+mJ=XHQN?>wQYD2Dim9l z`CJzF*~T-r?F2>J=7w#Ts<1Vt`AD)3)HeZ+Ogo)9Aah4~_l zKi2r#1eUmXVbNL$BRcoOyc>Ot*fYY&Q$j;K+4y#!WzWM>JdyCvFm1__Bz>=FlJdU} zB=vray4O+nH|W5YB1;`#EBXH#9a3)VainB#EY{_NUm1Jfn*7(xb>nbNIlLt!rR&G7 lL8RrkktB6&m-V(qlaxeC1Z7@AqD)CJ1cDSK5}*!1 zNtB%gNgtM?w4$4&rjvA{w4Pn6N!QA5cB|}WyLL9aQQFKFFc=fLWn1sIJ5$fjbZtwu zYtPPfzyI6^K#FqGZfCE=|DJR1d7uCMuk#-~C@yvhcuKB8-LrG9dGHVqu0rtJg@C&QLl@+c-Vf_ z-RohV-eOkVTf$0uOIc}e87u28XXU+K=0zR{t5BS*QYm7q6c<~qxLFncsud5bLA+Ke zW=|+3Y>iUN)+%ME*S4astPUyblyW93URJMEum+`)H7cuEld_tvSE|^C)6Qz4K~Sn| z1*K*u#zfCf?Io~{%66r;R=6OsP09}C2_Et(JC!v&)T}(ItmUB=WtXyBsiW`(k!?n7 zkFrjWwIa4xk@Z-cvQMe!Wwt2$l?EPaR}LtRJhT;|CLY?RJf*DXq3z1k$_5_Vp&V2; z^3YDy*`aKrFxq<(v1b&Y9^0jKDqTvm9@~v?-Aaod+rwj<_1Ip-4k@jAY@c#iX`?XP zuN+af@X!IJM``Dwr!W&+dFbiW2bFDmhXf&1EC{DN8idnNH<&ZFQ`Dv|B92*oy`fL6 zg3vLQJRfJ#WGETE9FY?db~(aY6VZ`~9JzWvdM=ub#$z&z({}_Wb8x@d~k%`GT<#0uL&4s;)pSutVClhV*;Q5Grj>WH_xAH`2TApN)%hAXc zIf?W{Xd+^Cb37gnjgL=nmd{NkU6U+6G8N{dNSu#PjgQEoa5yrVL@Bf9TSh|3(78|| zB9DirQ8{&p>WojZa72!eVq};ujP^L6hrO;=xg#8oPhnsza%n2cA|rA#E>BLK8;>TC zuT?%2Wr?J`ZO8e&@=h&1TrlD-=m=|*yD)}rEqmpy?T>sz71|URPDD5~xmVt{vmGPg zt7c3LGH*p{VmwZh$s%$rawU>TZefuy=C_r1E37Rf&qE#D+Fn?IVMXE-7|iA9<&e)@xE zp)i^=AJ9a4#0S3%_*LPwz(lZ7iy|`Xdf~JLW@$NXSF9WH^|XUIc}(K5BIe>T8;`k} zhsW$ZR?JFx%)w)&tc=H;JXX%UJXXYG6=0#%f{VvifssvV8DIuT8(9*rIZ`Q}%P^_{FoE6iwU0;?8G zM6X3uuaP(z4POj$N~e0K_}&eUM5t!1uGPlY3+g*mPcPN;(moGjr>W%;ZLbE60tvOm zNWwN7i^lE_pL~XorT1B-~FE!BNM{OO)*(ORY?;aW)=D80l;@9P*i+~a4>l(;Gw zOb~+(2Sdpui-Oxl62TyA!M6wa6Vw_|Ch+ww#uf@{jJj=dn$_Vu2ZS|=ivTYP@6>Ea z_04S0N*i+3O{rrujajKFmrq-_EiF~eR%E4Zxw`hWw0hQ>mD&q~4ZG7)?QCaO+MRo% zIn_U-WTocZ+P1W`Y9^7D+H$p}#mva=6q)S8EX%c)EfAnuqqtC)*WLBkQbRxp%N9(}>O0>t~SAr7>b`X(n z{3VDKrG!Oy$(NG1roT9S^M%>LOlf`A-H?_Vc)76IG2|pV#yfokfx=D)gaM>4e-zQ^ z-T{$ToSqR33WBu7_|hcYvHDd@bVL>ISX65w$yDoDBzZ?vtzh{HYF%c$??svj5R8tn zUZnQmFHsA?yMH}#>&lHQGs&BuStxC|ZOOVD(^BK73}6lg|A_+tZI^||4A>l2!GO`n z$L6;h(@>CKn;^;ff|a%TRktp0MMgTnowz(np(2gFBFG3?^tES)5oWYTOXg3Ljj(@M zKR)%*=iLB>^Y@u->H3AzQ*#xsSKX<)TbuRmN9+yjj~#D1?z^)G2N6pToyxkOOH0pv z%IrReYM0LL^6|C*KOhWy2Guh{UodY6BwP|s3rD&-#07p}N^q3LCxUtwHh?0};xADT zz=u8Kc%^uD8&D<#tl5z*-Lz2JHup@{y(KMe(Z)y9YCI!^ojMz&ER(`4Q1j%C2G4K}wJTR9C+rBpcMfPf<$`^$dW|P>k~b<7$EiD$Dw=UZpQiP)Bf$jk8n86Tn}B&@?6dh1)y3@tZBL zw%oqBP`fQtyDe8*mU9)~+VYjvU#?DdE|!$fY<#8V<(AotS#R@#ck5g@Te2;6I9FHy z>cn?0Wb1Z+>FKm|^am3F+OnFHjp~c=hq(CQx3M&5EoU8H6$UK@6RItbxwyviOjzymMMzjGgd^x+Od7Nd)42E(kXEeE{(3P2)H*0`#-?xE&$Y|MQ*Gw= z2o)wC|M%}=|EEi)zZRYe{L53{IyHASyMFh*-pu+#H!t1aKL6}sI`G^=;A|#vHXRIS z10xH8X!?9K`|O3x(F^kvS2IVi-nw+-($~TtC(2OUuT*zbi{IP~@MdWdpkFPyGzHZH zN>n%!8HtSe99%f!yn<7q{IJ;|Du9SGHQ{rrHj+jooV@XYva|TeMtP3$_!v7w(J_Do zQCV4j9u!YLsLg`AVCXD{&y+dw2EfND;hh4qR=#o0ku7ge^)7nqZ*QF2p7m@=b^nEX z)$FR<^AHPc*Z5m? z`(^_75qq32Bc^qFk;+04^t=R+4WPhY&XGgG%O)tPpULmvED z$@HrrqSIOTbgC;?yY~9#j8v6#m)_Vv*Y)jV-|9*~%G2|ch4yNy_$1q7@G#L$!o(c4lZZ~m>A zL^vQRjy5xfp4%0tB1j+~8nGM`d_{hC3`JEt;oek2^=MXu0gXyIwHQRfAZ2oJ(`3(x z&#rCdbCf7ug!M{wYLax%2utwlH0w(Az1% zdjT0PP|n{6NC^cTK3B8mdT~Zty;xbB>b&m!d|ys-yma{Uhp+o*yXK{OBi1`B%}Z-@ zuF?fpZN^nQyMNA`b!|>{()Zra_s)dp*YCS`IJ>^<{>c3E(e#Ch?DMho3!nW+5MC5J zE$?fu_XMHK(r0-+GAM8j&y$TUnT(XcW#F%gd? zREHkd`I!nEd^Cpr1dA&J4I&kaV=^Ie0)rC_T=hgj?t{7+ntcIZrYOs=0uYA+F}^i@ zV|unaTiUQty6N`0w@No%w=TL@&4j=4)PlP?>u#P)ynf}*mAlE;KlAO+mNqYxw$Gu0_D`+giCJZ)ZQdh)fC{#LoLGa7d|~S|?cyI+ z0=!we7x0J06#n6QH(;0sV?0EafmPyz4-g;`WksBMtbhUS4S2o$$y31P_|Y!J3Wa6M zN~QBc%kt8SsU;Wegq7=fEQaRmAOV|e)Y?*>AOpN=nVRIXjw+GO4mfZagJUuP@PzO| zwIe$eO4Y`V^hGKo!vwp5+@Hl?A^-rqDE8hub>q~`XR^iX7m8cvybGT8jHmtX z>a1t?g6FAw&!oD+ZLd#p&^S*`VgZD0FH*}=k z9ciiKQF22Bf-fgS;1@4vo|Wdw$yUoglr5P03fkC@K@5|aDAr>Frc?5>Nt9MgO{?`0 zZsH#_c3Ii+?t#7zf46@yc%-MRtJ|-Zo#^R5)-l-A@7Gfvu~$?^6J!B$CEucVY|wH4aHUa)Z_q+>+IJlQmh@N;UrhV86X4?+Hl+= zs9JGi#7)cGz{NJu2NDz^)Nq>4WfCIns*73((n;P`)gIQJ3Di z%wh^H`cXuXt32#?P~lDdB}m4BRjHzGcIvh0UF+|~vK2c&e=K!~tP>j+YTGll?b(v8 zsl)G-Rpz{_7E7vTtv6$fm1}3iU-su#t;>}lxh98?HH~-e3++7_{NK=%sp+}jwV+I9 z@c-Ckre^Z}3YW+Jp5U_CHDoTN>ooZsbp+7orKn>pFH8lN6V`-PHx@u*;E2;$fLbQK zH4vZ67FAf@#vK-@{a`g*?U!Pfm=y?7h`i_kJ&Fi#2Fo51b}f?n_!BUxGx}iTy^!=? ztkQZBv*~>)GRh2DhIWFl=q;da^n%(>NHHr~w>5!DBW9)8s20}0z-VCgFVW7a5>Peq z0i>K|lcR|A(U_uwslY%Azi{SZ@v|^;gG-Pdm7^Y=B2I>IP(y4{#}nuN@zBJ%kTZ(iTk^AHjxo&ZP*uhq=$xGUb-m95|Yy;Jv+Z*+YBN_OYrjC_PIH(Ih@ zj7B1`4-?p4I@t zxCE;i5K4sYCW6N-P|Ik|mOVT5IHy*sxYuWaF^KyOsCJMd_z!@y5lP3~!)%~hS!`^n zOi()#!9su_;rJMbR$Ad;0_skLy^ez4!e4@j6Xw-bcB}P^t+P93H{2F)Z@68Zu5Qk{ zTA;Onx$V8N_vU__MQM8-!!;k;BYgM`fM+dav^<7a-4iY8OB7U;uP#R3{&Rir)8$f1?2z!)SLDgzQxgM?)( zb}uv@P>Xm#u~9domOiw>DUCg53s|*$I>mr4ZR88s_hAV+jVgxzV*`~a*ULFj&atFi z121Q%`d6G8hdJI7Z5{-{G|S0)?M-^^&X{eOSUP_OyhbShZoq07^~H(;PJ^C?wxFh> z0Z_f=Prx}03a34ovuY!pe-9It;7`D5)NRba@$MJ52r*Z|TDTPFlC>aZVcCPVa0lGK z#9DYBnbX3x@H}=ciZNSxYe_MT#4z@fkyyi#Sjo^IkqE(Yg-&djJ?LNQP(RG(t~RSq z^+Er2n*U3Ld~VqK;PH_(JPA6M+HNJZ$GHQwO)|7b`SG?8vfyo{_YO2p^6o1Lv#${7 zB+vqYGfOd2zyTQTakWKr94e3pgSuPMqg>z)3M63s1yPvmn2TpNKlO(AM)RA+_b;WN z4gBOvrsX6j1A|~azejaD;JOfDIQZs$_%f2X9KM9>nd554=OyFfoXr>?S&EmBk^EV> zIHV1f#>oB+!16J&e~ZL_O&=No(D$u#LwEMw6Yp*MzWa^Pefyiy%P=NQqe_Qi9wUh7=1SSOMT~@8SBDl!t6Libt1NyukYc-{s2Hz z>QmeR#aD>;{@>9TnxVf>3G;17?w8-+@sq0bz=>?z$@Hn`(!=4*sgd;9`E)d%8JorOZSI~j=)}Mdb-5p zCe?g^x6YG!J&Q(-pDjIL>_)f)K;Pq2?PNjDp&bA&|XdV3Np+7m2 z^#$fng)_czs%x>Yd@&gLi`WhO(`Pe!3;odg_(>S5H$F&lzl5%DUF3I{)HS__}iI zxf{>Tx?by?voCCVCbQ|8A9db${pbE{)8Kq@ARWC3(H~tsl#zq!=tQPC_8t^8cqykR zFMlKmmk@$ckzPnIIcj-K&;NT>W7ggATQAPk&sBcw%H0c@jR$Xay!>LS3&wy&Z}n{D zeB-6`3!g((6&;q;(Ol`OOlf0!{odQ73mf)kHtbE8?88Cm>NWRHX58Irsr%y()(YO1 zkJXB#(-2c#d`>;%i#9kNJ2++hd&(Z-TP z4(UhxJcm5ee{tIZ!&VeB9&AKSCiyHD7sTe5I7W@(*fLgl*ky|0kOqMETqEet%OwHx z7}VsZkVi-_D9|*7ymXS!Z*WImzgOyQz_yZnpqM%o&I@cqV~V}-%%Bg#KDXZzlX6^p z16COFabD&*8iwwhkjXQGJ8lU2dEmX8*Dq4Q#Zn-Ocx9Hx9g#!bb0|P&6A6Q(|a=mlL zd82Q!VK)LL8P7U+PRzJ&^yfSkxBNH!-zmD=`0bK6w*Pq7o4fAs$#w>C)XIrjKhds} zmQzH@)||I|C~r7nfWaovlIo26bI#vxe(%|pjaRt z>VstDTG7a13g89KHCIpq)dU<$QEM@3bL77TAchlpKy9Y-gsA=~t|f<(E9Y?+=IJ~8 zOZ1H9n?HMvo;mfNIrC0GM$d*#B@HRuJ9lWW=9k;|6^*nEL)h1zfO%M1kfyJRp_nZB z!sTc4OSw^xVq21nuZfv!h1Q5D8>D>bs^V$07peokV*eeH3woLmM1qPY6aw9J>FCQ8 znj{b=VA2=R4zLkjdZL1AiD47b?AfXt($x5L2fvb_N3<`nXda}ZB;9BNA7)!%R|ya? zVSh~EKLNl>8M+E8v`lO}NuM0LW}t~~?#rN~WKQO?8*dq|Y;+rfdpUz}fKI}Zo_if| zVv`{K$`r*6VXV@iGR9pO@=|6C$oi-FOOP}VF|5Gtpk(8m?Os==qyvIOQ_I|uOw-PL zjyH-jyL;bi>dktO0?uBV8T`twOjB>_Xxe);*Ff%;sbjhF>e-t4^2Y17TuH^uksI;r z)||U+rt-$V*_Nz(eXi8Yy)>_8OXb%N;hZa1w&u2&DXX9HEUszLNKnpGdh76w!!!Qd zUGpBF5$nAzz3o~5PGR}}Z0YvG@+*_DoBi)UA$Xe}t`Vwg(TCL=zSnqn?B4mzlZUh0 zj?4#OzIr|#x{x__F&h|vh?_!|E~04NmP2&Zd&F{-V#nZbfY+eq43W8Gma}v;e8du@ zEaxl{`Zj90M2Y8c0}WY@;DuO+B|+c1tjDcL^u(-)*kaa?Kd2Wfj)@7PHQz4jYFa8m zk(C7!mH6PV5%_12ppJf;1Qig8z;VmTYDg`ng~yO%6bnN98bIDx5GnjB03;gj#080l zux{D2lDuYYYB(K%q?&SyT{}D)WH(XAlFfLK5;&vorO`f1 zi-n2}@0AE1Z>mpYmxU6wi4V?y9*O$QKLQ^dm;wIeWoSd@<~W1M61?EDZvoDjtY-zB z0c%|T`~sYTS4$peuI=vN*Ov5aKm4|ydHA?xi6lu^jq(@p+Bo4qv>e%Vudh6^anUeO zxeKt1spz>&Li3*!dvC7{u}+@=(!#Ky8mrYi-~BJss2Y1jTV~|9uhVS?Jtj! z!r-w=QdoY`hQy&p9QjCa*=pC79dV@-NSHO9XkyueR1;nDpH?;-D^PvfY_R_Ddq+dv zFbX=zDCl$2*(`BV7<~Q!FU|Mpy9f=!v(cm96gtK)3K1)ZC4&wT^Og}UR;=CVfua9m zC+_o9Xg5oi+#=0O2HG`Ac#E#n#rQ7$hMp!EbR-OWqiQ9#OPw)%3`xG{e3;c!sDVHu z0or9cEn{D!1amMjE@{dFdl%`CAe)Cx#Y|O*{V!w@KsFL2Yw&Z1o14g4x@E5WULsS{ z2?fFhLUwmY#?^t-hPwJRyr!fl-l?uzsNS5Z-khsiyHM4fscO!dDK+aBYPMu*w&d0| zzVEiLv!}YXGN51WyV564Wgc@iz$9b;6-m~j3x;K+sYfH^NMu@8b*^)FKjm5`^kxG)FKP(1R4MQ z?FX#u1oD;jt0McvHkBN0-3{BXGZG8ONpHPYe=^Q+_qG>WBW_$mJ)W4t1zB8D#KlC7 zGx_YQ6o((3DqY|X#^dqO2>U$7$7mC%t`nj0sYo|taR$V~$zt-nDvrWA1-BNL9y1kD z$skq2{vIVu(No=JK37zHtL8?{%=X(=*K0CGzPV=?ODpJ(&usb47q3enSp`?~U&B3V z~GN>_P+rR;qXq}%zht1 zgglX0Vv0pzG6+YbYbijMPDqMX@X$g{Fbwu&;CM@?`@i0n!%T=wpzdIdfE|2 z*nCWq{08d&#Rr_UefEek+FT@CE_)PfUcSSb9MJhO`7rg0<5Rvnf601^@>}my+jHgf zfSBPk+?P!-8}&(|%OBUuT3bn0Z#6}{Yt34ll&Vu#-XN~Mn|X9axy@IkI#D6E zd?KQjbg(fvOvIAMDauq&Xk;XauRM*>4q`F^+S6n&cjP~7xZaZ`G0GE+R2I+T-K3sMza>SsOQ*@5Hz3~ra2Nkwy;dD5Dh^ms z0u&Jj?1DngcN^~|)4i8@ZC7&c@&)&rjC)PiU6+>XG@%j$HXoBYya;x_qEK0I?+v?b z1y*mec`?t{k&|j(_8@g75mH#m^MDJl7Wk!FQOcwU`3g7b!B91X1an^xm93Q55G){u zYWEU^m3#q~)L3D(gc`j@IR7rFk!#THe&fVd1}Da zO6Sk~8Ll-#18a;>{tXe#N^Bau!gQzMlyZf$dt>Y)m`SuP=W8)SMvjmVJ#92nSI%2t zFb4JKZLWYf5D;|{5|^8txH|!MUXO|k#O%LBR4p~BgeI`F|Ay2f#&tF}podup1XVIx5!q1rTpZ3S&+ zBuwB^l|&RKZzWV6xOaqK517_(R2fo;G!cT+V+}gejK4$%g+XrIt8VVck*Npb-7E2z z<5|xJxEU8eG1s)vyg$>t|Nf4Jqo*@RPp8Kx7sfxE!T;jVfhN_-F!(v#FTMEEi;Hzz z?%MwF#qYkDt?Rn(m~myCPvFafqZa=c*Kfbuy|B-p+2_x$@4xOKJBPCYBIB+7xAxzb z7Ub>t&z0D_JN|IbclTtx2d+C7rJ^r2-D>_~^UTS)o%hN!u6^^;eroL6OV?%wUm1F3 zXwg|d)AY)wmp3hVTW3eU8GkiCH#P6vnR9zMU7?>#ps@yn+!-s9oMJIfpnVcoyg#X4 z`Wqd?IHle!<0n1NuqSAl4)JN_Ss77gEmhW1pT+Bwb&sn+eo^+vW08D4Y=(;2wd*cx zsWMVJKPfhT6=WFet2ZlCPTl{MH%m=1vka>KE$~Ye`V9hqOyK7PIC>tWP%nXg z0s{n25r`3Zfxs6C+#v9q1YRcaI|SwkyiVZ91b#|jfdJgU}&i&dC%RI$ekRAVp#Iu0!Sw&HNU#bwLzuP2;jejMS{+ZzZ zGocvp&xQIw7pnhK=vxr_GD6=wWfiGn9NeTlIUZVFofax`tE$q1SBEuEq=nTvZ)K`9 z=dA!J^P)h>nv^?NTsqT|DPEg$OueCV-=)gQXN;vwmIHt5sTkz5G2p_Yguc}&s~)-4}SSX*0m-r Nt$EKXNVS|r{a;Q5lvw}( diff --git a/server/rotation_policy.json b/server/rotation_policy.json new file mode 100644 index 0000000..b288ebc --- /dev/null +++ b/server/rotation_policy.json @@ -0,0 +1,12 @@ +{ + "display_limit": 250, + "newest_slots": 150, + "revival_slots": 100, + "revival_sample_size": 50, + "revival_pick_count": 20, + "upvote_delay_slots": 20, + "downvote_advance_slots": 25, + "upvote_rank_cap": 50, + "extreme_downvotes": 10, + "extreme_margin": 8 +} diff --git a/server/rotation_worker.py b/server/rotation_worker.py index 5415873..7da1e69 100644 --- a/server/rotation_worker.py +++ b/server/rotation_worker.py @@ -65,6 +65,33 @@ class RotationConfig: extreme_downvotes: int = EXTREME_DOWNVOTES extreme_margin: int = EXTREME_MARGIN + def __post_init__(self) -> None: + self.display_limit = max(0, int(self.display_limit)) + self.newest_slots = max(0, int(self.newest_slots)) + self.revival_slots = max(0, int(self.revival_slots)) + if self.newest_slots + self.revival_slots > self.display_limit: + self.revival_slots = max(0, self.display_limit - self.newest_slots) + self.newest_slots = min(self.newest_slots, self.display_limit) + + @classmethod + def from_mapping(cls, values: MutableMapping[str, Any] | None) -> "RotationConfig": + data = values or {} + allowed = set(cls.__dataclass_fields__) + clean = {key: int(value) for key, value in data.items() if key in allowed and value is not None} + return cls(**clean) + + +def load_config(path: Optional[Path], overrides: MutableMapping[str, Any] | None = None) -> RotationConfig: + data: Dict[str, Any] = {} + if path: + with path.open("r", encoding="utf-8") as f: + loaded = json.load(f) + if not isinstance(loaded, MutableMapping): + raise ValueError("Rotation config JSON root must be an object") + data.update(loaded) + data.update({k: v for k, v in (overrides or {}).items() if v is not None}) + return RotationConfig.from_mapping(data) + def now_ms() -> int: return int(time.time() * 1000) @@ -287,9 +314,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--write", action="store_true") parser.add_argument("--out", type=Path) parser.add_argument("--seed", type=int) - parser.add_argument("--display-limit", type=int, default=DISPLAY_LIMIT) - parser.add_argument("--newest-slots", type=int, default=NEWEST_SLOTS) - parser.add_argument("--revival-slots", type=int, default=REVIVAL_SLOTS) + parser.add_argument("--config", type=Path, help="Optional rotation policy JSON file.") + parser.add_argument("--display-limit", type=int) + parser.add_argument("--newest-slots", type=int) + parser.add_argument("--revival-slots", type=int) parser.add_argument("--restore", nargs="*", default=None, help="Admin restore object IDs from permanent/violation hidden to rotation hidden.") parser.add_argument("--hide-violation", nargs="*", default=None, help="Admin hide object IDs as violation_hidden.") return parser.parse_args() @@ -298,7 +326,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() state = load_json(args.world_json) - config = RotationConfig(display_limit=args.display_limit, newest_slots=args.newest_slots, revival_slots=args.revival_slots) + config = load_config(args.config, {"display_limit": args.display_limit, "newest_slots": args.newest_slots, "revival_slots": args.revival_slots}) summary: Dict[str, Any] = {} if args.restore: summary["restored"] = restore_permanent(state, args.restore) diff --git a/server/test_rotation_worker.py b/server/test_rotation_worker.py new file mode 100644 index 0000000..deae99f --- /dev/null +++ b/server/test_rotation_worker.py @@ -0,0 +1,64 @@ +import tempfile +import unittest +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from rotation_worker import ( + ACTIVE, + HIDDEN_ROTATION, + VIOLATION_HIDDEN, + RotationConfig, + account_publish_limit, + apply_exhibition_cap, + hide_violation, + load_config, + restore_permanent, +) + + +class RotationWorkerTest(unittest.TestCase): + def test_account_publish_limit_changes_after_first_day(self): + now = 2_000_000_000 + self.assertEqual(account_publish_limit({"id": "a", "createdAt": now}, now), 5) + self.assertEqual(account_publish_limit({"id": "a", "createdAt": now - 25 * 60 * 60 * 1000}, now), 10) + + def test_exhibition_cap_hides_old_excess_objects(self): + state = { + "assets": [], + "placed": [ + {"id": "old", "assetId": "a", "x": 1, "y": 1, "publishedAt": 10}, + {"id": "new", "assetId": "b", "x": 2, "y": 2, "publishedAt": 20}, + ], + "dynamicSummons": [], + "objectVotes": {}, + } + summary = apply_exhibition_cap(state, RotationConfig(display_limit=1, newest_slots=1, revival_slots=0), seed=1, now=30) + self.assertEqual(summary["active"], 1) + self.assertEqual(state["placed"][1]["status"], ACTIVE) + self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION) + + def test_display_limit_caps_slot_total(self): + config = RotationConfig(display_limit=3, newest_slots=5, revival_slots=5) + self.assertEqual(config.newest_slots + config.revival_slots, 3) + + def test_violation_hide_and_restore(self): + state = {"placed": [{"id": "obj", "assetId": "a"}], "dynamicSummons": []} + self.assertEqual(hide_violation(state, ["obj"], now=1), ["obj"]) + self.assertEqual(state["placed"][0]["status"], VIOLATION_HIDDEN) + self.assertEqual(restore_permanent(state, ["obj"], now=2), ["obj"]) + self.assertEqual(state["placed"][0]["status"], HIDDEN_ROTATION) + + def test_load_config_from_json_with_override(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / "policy.json" + path.write_text('{"display_limit": 40, "newest_slots": 30}', encoding="utf-8") + config = load_config(path, {"revival_slots": 7}) + self.assertEqual(config.display_limit, 40) + self.assertEqual(config.newest_slots, 30) + self.assertEqual(config.revival_slots, 7) + + +if __name__ == "__main__": + unittest.main() diff --git a/styles.css b/styles.css index 959cb18..cad0ac7 100644 --- a/styles.css +++ b/styles.css @@ -1459,3 +1459,37 @@ body, button, input, select, textarea { font-size: 15px; } @media (max-width: 760px) { .analogClock { top: 82px; right: 10px; transform: scale(.86); transform-origin: top right; } } + + +/* v16 layout refinements */ +.inlineField { + display: grid; + grid-template-columns: 86px minmax(0, 1fr); + align-items: center; + gap: 8px; +} +.inlineField > input, +.inlineField > select { margin-top: 0 !important; } +.quickSetupBar.twoCols { gap: 8px 12px; } +.compactNameField.inlineField { margin-top: 6px; } +.tabs .tab[data-tab="settings"] { display: inline-flex; } + +.assetCard { cursor: pointer; align-items: center; } +.assetCard:not(.expanded) .assetMeta { align-self: center; } +.assetCard:not(.expanded) { padding: 7px 8px; } +.assetCard:not(.expanded) .assetMeta strong { font-size: 13px; } +.assetCard:not(.expanded) .assetMeta span { font-size: 11px; margin-top: 2px; } +.assetCard.expanded { align-items: start; } + +.selectionBubble { + position: fixed !important; + z-index: 50 !important; +} + +.analogClock { + top: 218px; + right: 30px; +} +@media (max-width: 760px) { + .analogClock { top: 176px; right: 12px; transform: scale(.82); transform-origin: top right; } +} diff --git a/styles.css.bak b/styles.css.bak deleted file mode 100644 index 8c3b475..0000000 --- a/styles.css.bak +++ /dev/null @@ -1,901 +0,0 @@ -:root { - --ink: #243044; - --muted: #6f7b91; - --paper: #fff7e8; - --panel: rgba(255, 248, 232, .94); - --panel-solid: #fff8e9; - --panel-2: #ffeec9; - --line: #2d3c50; - --line-soft: rgba(45, 60, 80, .18); - --pink: #ff85b3; - --pink-dark: #f05f98; - --mint: #73d6a4; - --sky: #77c9ff; - --sun: #ffd66e; - --danger: #ff6b6b; - --shadow: 6px 6px 0 rgba(34, 45, 63, .14); - --hard-shadow: 4px 4px 0 rgba(34, 45, 63, .22); -} - -* { box-sizing: border-box; } - -html, body { height: 100%; } - -body { - margin: 0; - color: var(--ink); - background: #92d8ff; - font-family: "Courier New", "Monaco", "Lucida Console", ui-monospace, monospace; - overflow: hidden; -} - -button, input, select, textarea { font: inherit; } -button { -webkit-tap-highlight-color: transparent; } - -.app { - position: relative; - width: 100vw; - height: 100vh; - overflow: hidden; -} - -#worldCanvas { - display: block; - width: 100vw; - height: 100vh; - image-rendering: pixelated; - cursor: grab; - background: #8cd4ff; -} - -#worldCanvas.dragging { cursor: grabbing; } -#worldCanvas.placeCursor { cursor: copy; } -#worldCanvas.eraseCursor { cursor: not-allowed; } - -.hud { - position: absolute; - z-index: 5; - background: var(--panel); - border: 2px solid var(--line); - border-radius: 0; - box-shadow: var(--shadow); - backdrop-filter: blur(8px); -} - -.topHud { - top: 14px; - left: 14px; - right: 14px; - min-height: 70px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 14px; - pointer-events: none; -} - -.logo { - font-weight: 900; - letter-spacing: .04em; - font-size: 20px; -} - -.logo span { - display: inline-block; - background: var(--pink); - border: 2px solid var(--line); - padding: 1px 6px; - margin-left: 6px; - font-size: 12px; - transform: rotate(-2deg); -} - -.subline { - margin-top: 3px; - color: var(--muted); - font-size: 12px; -} - - -.authorCard { - width: 190px; - border: 2px solid var(--line); - background: #fffdf5; - padding: 6px 8px; - pointer-events: auto; -} -.authorCard span { - display: block; - font-size: 10px; - font-weight: 900; - letter-spacing: .08em; - text-transform: uppercase; - color: var(--muted); -} -.authorCard input { - margin-top: 3px; - padding: 5px 6px; - border-width: 2px; - background: #fffaf0; -} - -.clockCard { - width: 230px; - border: 2px solid var(--line); - background: #fffdf5; - padding: 8px 10px; -} - -.phaseLabel { - font-weight: 900; - letter-spacing: .06em; - text-transform: uppercase; - font-size: 13px; -} - -.phaseBar { - margin-top: 6px; - height: 10px; - background: #dfe8f0; - border: 2px solid var(--line); - overflow: hidden; -} - -.phaseBar span { - display: block; - width: 0%; - height: 100%; - background: linear-gradient(90deg, #ffd66e, #77c9ff, #6862e8); -} - -.clockHint { - margin-top: 4px; - color: var(--muted); - font-size: 11px; -} - -.edgeAdd { - position: absolute; - z-index: 8; - left: 0; - top: 50%; - width: 56px; - height: 86px; - transform: translateY(-50%); - border: 3px solid var(--line); - border-left: 0; - border-radius: 0 18px 18px 0; - background: var(--pink); - color: white; - font-size: 42px; - font-weight: 900; - line-height: 1; - box-shadow: var(--hard-shadow); - cursor: pointer; -} - -.edgeAdd:hover { background: var(--pink-dark); } -.edgeAdd:active { transform: translateY(calc(-50% + 2px)); box-shadow: 2px 2px 0 rgba(34,45,63,.22); } - - .toolHud { - right: 14px; - top: 116px; - display: grid; - grid-template-columns: 1fr; - gap: 7px; - padding: 9px; -} - -.iconButton, -button, -.ghostButton, -.tab, -.tool, -.segmented button { - border: 2px solid var(--line); - border-radius: 0; - background: #fffdf5; - color: var(--ink); - padding: 8px 11px; - font-weight: 850; - cursor: pointer; - box-shadow: 3px 3px 0 rgba(36, 48, 68, .16); - transition: transform .05s, box-shadow .05s, background .12s; -} - -button:hover, -.ghostButton:hover, -.tab:hover, -.tool:hover, -.segmented button:hover { background: #fff1c8; } - -button:active, -.ghostButton:active, -.tab:active, -.tool:active, -.segmented button:active { - transform: translate(2px, 2px); - box-shadow: 1px 1px 0 rgba(36, 48, 68, .16); -} - -button.active, -.tab.active, -.tool.active, -.segmented button.active { - background: var(--sun); - box-shadow: inset 0 0 0 2px rgba(255,255,255,.4), 3px 3px 0 rgba(36,48,68,.18); -} - -button.primary { - background: var(--mint); - color: #163a2a; -} - -button.secondary { background: var(--panel-2); } -button.danger, .tool.danger, .iconButton.danger { background: #ffe4e4; color: #833232; } -button.danger.active, .iconButton.danger.active { background: var(--danger); color: #fffdf5; } - -.divider { - height: 2px; - background: var(--line); - opacity: .22; - margin: 2px 0; -} - -.infoHud { - right: 14px; - bottom: 14px; - width: min(360px, calc(100vw - 28px)); - padding: 12px; -} - -.selectedAsset { - font-weight: 900; - padding-bottom: 8px; - border-bottom: 2px dashed rgba(45,60,80,.25); -} - -.tileInfo { - white-space: pre-wrap; - color: var(--muted); - font-size: 12px; - line-height: 1.45; - margin-top: 8px; -} - -.studioDrawer { - position: absolute; - z-index: 7; - top: 0; - left: 0; - width: min(500px, calc(100vw - 20px)); - height: 100vh; - background: var(--panel-solid); - border-right: 3px solid var(--line); - box-shadow: 10px 0 0 rgba(36, 48, 68, .12); - transform: translateX(-104%); - transition: transform .18s ease-out; - display: grid; - grid-template-rows: auto auto 1fr; -} - -.studioDrawer.open { transform: translateX(0); } - -.drawerHead { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - padding: 10px 12px 8px; - border-bottom: 3px solid var(--line); - background: #ffe6f0; -} - -.drawerHead h1 { - margin: 0; - font-size: 24px; - font-weight: 950; -} - -.drawerHead p { - margin: 3px 0 0; - color: var(--muted); - font-size: 12px; -} - -.ghostButton { - width: 36px; - height: 36px; - padding: 0; - font-size: 24px; - line-height: 1; -} - -.tabs { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 6px; - padding: 8px; - background: #fff2d0; - border-bottom: 3px solid var(--line); -} - -.tab { padding: 8px 5px; font-size: 13px; } - -.drawerBody { - overflow-y: auto; - padding: 8px; - background: - linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px, - var(--panel-solid); -} - -.tabPanel { display: none; } -.tabPanel.active { display: block; } - -.compactNameField { margin-bottom: 5px; } -.roleHintInline { margin: 0 0 10px; } - -.card { - background: #fffdf5; - border: 2px solid var(--line); - box-shadow: 4px 4px 0 rgba(36,48,68,.12); - padding: 9px; - margin-bottom: 8px; -} - -.stack > * + * { margin-top: 10px; } -.cardTitle { - font-weight: 950; - text-transform: uppercase; - letter-spacing: .06em; - font-size: 12px; - color: #3c4b60; -} - -.field { - display: block; - color: var(--muted); - font-size: 12px; - font-weight: 800; -} - -input, select, textarea { - display: block; - width: 100%; - margin-top: 5px; - border: 2px solid var(--line); - border-radius: 0; - background: #fffaf0; - color: var(--ink); - padding: 9px 10px; - outline: none; -} - -input:focus, select:focus, textarea:focus { background: #fff; box-shadow: 0 0 0 3px rgba(119,201,255,.35); } -textarea { resize: vertical; } -input[type="color"] { height: 39px; padding: 3px; } -input[type="checkbox"] { width: auto; margin: 0 8px 0 0; } - -.twoCols { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.segmented { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 7px; -} - -.segmented button { font-size: 12px; padding: 8px 5px; } - -.hint { - color: var(--muted); - font-size: 12px; - line-height: 1.5; - margin: 0; -} - -.editorCard { padding-bottom: 10px; } -.editorTop { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 9px; -} - -.sideSwitcher { - display: flex; - gap: 5px; - align-items: center; -} -.sideSwitcher button { padding: 6px 7px; font-size: 11px; } - -.toolRow { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 6px; - align-items: center; - margin-bottom: 8px; -} - - -.editorToolRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } -.editorHistoryRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } -.editorMoveRow { grid-template-columns: repeat(5, minmax(0, 1fr)); } -.editorFileRow { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.toolRow input { margin: 0; } -.tool { padding: 7px 5px; font-size: 11px; } -button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; } - -#paintCanvas { - display: block; - width: min(100%, 330px); - margin: 0 auto; - aspect-ratio: 1 / 1; - image-rendering: pixelated; - border: 3px solid var(--line); - background: - linear-gradient(45deg, #f4ebdc 25%, transparent 25%) 0 0/16px 16px, - linear-gradient(45deg, transparent 75%, #f4ebdc 75%) 0 0/16px 16px, - #fffaf0; - cursor: crosshair; - user-select: none; - touch-action: none; -} - -.compactSettings .twoCols { align-items: center; } -.toggleLine { - color: var(--ink); - font-size: 13px; - font-weight: 850; - display: flex; - align-items: center; -} - -.behaviorNote { - border: 2px dashed rgba(45,60,80,.22); - padding: 8px; - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} - -.actionRow { - display: flex; - gap: 9px; - margin: 12px 0; -} -.actionRow.wrap { flex-wrap: wrap; } -.actionRow button { flex: 1; } -.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; } - -.assetList { display: grid; gap: 10px; } -.assetCard { - display: grid; - grid-template-columns: 68px 1fr; - gap: 10px; - border: 2px solid var(--line); - background: #fff9e8; - padding: 8px; - box-shadow: 3px 3px 0 rgba(36,48,68,.12); -} -.assetCard.selected { background: #e6ffef; } -.assetPreview { - width: 64px; - height: 64px; - image-rendering: pixelated; - background: #fff3d9; - border: 2px solid var(--line); -} -.assetMeta strong { display: block; font-size: 14px; } -.assetMeta span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; line-height: 1.3; } -.assetActions { - display: flex; - flex-wrap: wrap; - gap: 5px; - margin-top: 8px; -} -.assetActions button { padding: 5px 7px; font-size: 11px; } - -.modeGrid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -} -.modeGrid button { padding: 8px 5px; font-size: 12px; } - -.legend { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - font-size: 12px; - color: var(--muted); -} -.legend span { display: flex; align-items: center; gap: 6px; } -.legend i { - display: inline-block; - width: 18px; - height: 18px; - border: 2px solid var(--line); -} -.legend .water { background: #67b5d9; } -.legend .sand { background: #ead493; } -.legend .grass { background: #8bce76; } -.legend .highland { background: #a7be80; } - -#dataBox { min-height: 160px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; } - -.toast { - position: absolute; - z-index: 20; - left: 50%; - bottom: 22px; - transform: translateX(-50%); - background: #fffdf5; - border: 3px solid var(--line); - box-shadow: var(--hard-shadow); - padding: 10px 14px; - font-weight: 900; -} - -[hidden] { display: none !important; } - -@media (max-width: 760px) { - .topHud { align-items: flex-start; flex-direction: column; right: 70px; } - .clockCard, .authorCard { width: 100%; } - .toolHud { top: auto; bottom: 122px; right: 10px; } - .infoHud { left: 10px; right: 10px; width: auto; } - .studioDrawer { width: calc(100vw - 18px); } - .toolRow { grid-template-columns: 44px repeat(3, minmax(0, 1fr)); } -} - -/* refinements */ -.toolHud { - top: 142px; - right: 18px; -} -.edgeAdd { - top: calc(50% + 24px); - width: 62px; - height: 92px; - background: linear-gradient(180deg, #ff97c2, #ff75aa); -} -.studioDrawer { - width: min(520px, calc(100vw - 28px)); -} -.drawerHead { background: #ffe1ef; } -.drawerBody { - background: - linear-gradient(90deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px, - linear-gradient(0deg, rgba(255,255,255,.42) 0 1px, transparent 1px) 0 0/20px 20px, - #fff8e9; -} -.card { - border-width: 3px; - box-shadow: 5px 5px 0 rgba(36,48,68,.13); -} -.heroEditor { - background: #fffdf7; - border-color: #26364c; -} -#paintCanvas { - margin-bottom: 10px; - max-height: min(42vh, 330px); -} -.toolRow { - grid-template-columns: repeat(5, minmax(0, 1fr)); - margin-top: 0; -} -.paletteGrid { - display: grid; - grid-template-columns: repeat(13, 1fr); - gap: 3px; - margin: 6px 0 6px; - padding: 6px; - border: 2px solid rgba(36,48,68,.28); - background: #fff6df; -} -.paletteSwatch { - position: relative; - aspect-ratio: 1 / 1; - min-height: 18px; - border: 2px solid rgba(36,48,68,.38); - box-shadow: 2px 2px 0 rgba(36,48,68,.13); - cursor: pointer; -} -.paletteSwatch.active { - outline: 3px solid #ff7aad; - outline-offset: 1px; - border-color: #26364c; -} -.paletteSwatch::after { - content: attr(data-code); - position: absolute; - right: 2px; - bottom: 0; - color: rgba(20,26,38,.52); - font-size: 7px; - font-weight: 900; - text-shadow: 0 1px 0 rgba(255,255,255,.7); -} -.quickSetupBar { margin-bottom: 6px; } -.settingsCard .segmented { margin-top: 2px; } -.markerRow { - display: grid; - gap: 6px; -} -.markerChip { - border: 2px dashed rgba(36,48,68,.25); - background: #fff6df; - padding: 8px; - font-size: 12px; - color: var(--muted); - font-weight: 800; -} - -.summonCard { - background: linear-gradient(180deg, #fffdf5, #fff2d7); -} -.finishActions { - display: grid; - grid-template-columns: 1.35fr .8fr .6fr; -} -.bigPrimary { - min-height: 48px; - font-size: 14px; - background: linear-gradient(180deg, #89e4ae, #65d596) !important; -} -.sideSwitcher button:first-child.active::after, -.sideSwitcher button:nth-child(2).active::after { - content: ""; - display: inline-block; - width: 6px; -} -.spawnPop { - pointer-events: none; -} -.selectedAsset { font-weight: 900; padding-bottom: 8px; border-bottom: 2px dashed rgba(45,60,80,.25); } -@media (max-width: 720px) { - .toolHud { top: auto; bottom: 126px; right: 10px; } - .finishActions { grid-template-columns: 1fr; } - .paletteGrid { grid-template-columns: repeat(8, 1fr); } -} - -.paletteGrid button { width: 100%; aspect-ratio: 1 / 1; padding: 0; min-height: 22px; } -.sideSwitcher button { white-space: nowrap; } -.heroEditor .hint { line-height: 1.35; } - - -.compactEditorTop { margin-bottom: 4px; min-height: 0; } -body, button, input, select, textarea { font-smooth: never; -webkit-font-smoothing: none; } - -/* latest layout fixes */ -.studioDrawer { width: min(600px, calc(100vw - 24px)); } -.paintLayout { - display: grid; - grid-template-columns: minmax(220px, 360px) 1fr; - align-items: start; - gap: 10px; - margin: 4px 0 8px; -} -#paintCanvas { - width: 100%; - max-width: 360px; - max-height: none; - margin: 0; -} -.paletteGrid { - margin: 0; - grid-template-columns: repeat(6, 1fr); - align-content: start; - gap: 4px; - max-height: 360px; - overflow: hidden; -} -.paletteSwatch, .paletteGrid button { - min-height: 20px; -} -.sideSwitcher { justify-content: flex-start; } -.authorCard span { white-space: nowrap; } -@media (max-width: 720px) { - .paintLayout { grid-template-columns: 1fr; } - .paletteGrid { grid-template-columns: repeat(10, 1fr); max-height: none; } -} - -/* Selection bubble + outline controls */ -.palettePanel { display: grid; gap: 8px; } -.selectionBubble { - position: absolute; - left: 0; - top: 0; - z-index: 9; - min-width: 126px; - max-width: 210px; - padding: 8px; - background: #fffdf5; - border: 3px solid var(--line); - box-shadow: 4px 4px 0 rgba(36,48,68,.20); - pointer-events: auto; - text-align: center; -} -.selectionBubble::after { - content: ""; - position: absolute; - left: 50%; - bottom: -10px; - width: 14px; - height: 14px; - background: #fffdf5; - border-right: 3px solid var(--line); - border-bottom: 3px solid var(--line); - transform: translateX(-50%) rotate(45deg); -} -.bubbleName { - font-size: 12px; - font-weight: 950; - line-height: 1.2; - word-break: break-word; -} -.bubbleAuthor { - margin-top: 2px; - font-size: 10px; - color: var(--muted); - font-weight: 800; -} -.bubbleVotes { - position: relative; - z-index: 1; - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 6px; - align-items: center; - margin-top: 6px; -} -.bubbleVotes button { - padding: 3px 7px; - min-width: 30px; - font-size: 11px; -} -#voteScore { - font-weight: 950; - font-size: 12px; - min-width: 20px; -} - -.bubbleRemixFrom, .bubbleRemixCount { - margin-top: 3px; - color: var(--muted); - font-size: 10px; - font-weight: 800; -} -.bubbleActions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 5px; - margin-top: 6px; -} -.bubbleActions button { - padding: 4px 6px; - font-size: 10px; -} -.bubbleVotes button.mutedVote, .assetActions button.mutedAction { - opacity: .45; -} -.bubbleVotes button.activeVote { - background: var(--sun); -} - -/* Pixel-ish UI refinements */ -body, button, input, select, textarea { - font-family: "Courier New", "Lucida Console", Monaco, monospace; - letter-spacing: .02em; -} -button, .tab, .tool, .iconButton, .cardTitle, .logo { - text-transform: uppercase; - font-weight: 900; -} -.libraryHead { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} -.hiddenAssetPanel { - border: 2px dashed rgba(36,48,68,.35); - background: #fff3d6; - padding: 8px; -} -.compactAssetList .assetCard { opacity: .82; } -.librarySectionTitle { - margin: 12px 0 6px; - padding: 5px 7px; - background: #ffe6f0; - border: 2px solid var(--line); - box-shadow: 3px 3px 0 rgba(36,48,68,.12); - font-size: 12px; - font-weight: 900; -} -.advancedRow { - display: grid; - grid-template-columns: max-content 1fr; - gap: 8px; - align-items: center; - margin-top: 8px; -} -.advancedOnly.active { background: #bde9ff; } -.depthLegend { - font-size: 10px; - color: var(--muted); -} - -/* Simplified current UI */ -body, button, input, select, textarea { - font-family: "MS Gothic", "Osaka-Mono", "DotumChe", "Courier New", ui-monospace, monospace; - letter-spacing: .02em; - -webkit-font-smoothing: none; - text-rendering: geometricPrecision; -} -.toolRow { grid-template-columns: repeat(6, minmax(0, 1fr)); } -.editorHistoryRow { grid-template-columns: repeat(2, minmax(0, 1fr)); } -.advancedRow { - grid-template-columns: max-content max-content max-content 1fr; - gap: 6px; -} -.librarySectionTitle { - display: block; - width: 100%; - text-align: left; - margin: 10px 0 6px; - cursor: pointer; -} -.librarySectionTitle::after { - content: " click: genre"; - float: right; - color: var(--muted); - font-size: 10px; -} -.assetSectionGrid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - max-height: 360px; - overflow-y: auto; - padding-right: 4px; -} -.libraryEmptyNote { - color: var(--muted); - font-size: 12px; - border: 2px dashed rgba(36,48,68,.2); - padding: 8px; - background: #fff9e8; -} -.assetSectionGrid .assetCard { - grid-template-columns: 54px 1fr; - gap: 8px; -} -.assetSectionGrid .assetPreview { - width: 52px; - height: 52px; -} -.assetSectionGrid .assetActions button { padding: 4px 5px; font-size: 10px; } -@media (max-width: 720px) { - .assetSectionGrid { grid-template-columns: 1fr; max-height: none; } - .toolRow { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .editorHistoryRow { grid-template-columns: 1fr 1fr; } - .advancedRow { grid-template-columns: 1fr 1fr 1fr; } -} - - -.syncStats { - padding: 8px 10px; - border-radius: 12px; - background: rgba(20, 25, 32, 0.06); - color: var(--muted); - font-size: 12px; - line-height: 1.45; -} From 8ee6a87d4445d23240158ec25d8209052043d8a1 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 21:43:57 +0900 Subject: [PATCH 3/6] updated --- app.js | 1876 ++++++++++++++++++++++++++--------- index.html | 50 +- js/editor-actions.js | 10 +- js/phase2-sync.js | 96 +- server/test_world_policy.py | 131 +++ server/world_policy.py | 299 ++++++ styles.css | 415 +++++++- 7 files changed, 2317 insertions(+), 560 deletions(-) create mode 100644 server/test_world_policy.py create mode 100644 server/world_policy.py 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 @@
-
Left-click: select · Right-click: clear
- - + - Save + Place creates a local ID, name, and password. Change the password after creation. + Save + Place creates a local account for island publishing.
@@ -67,30 +65,22 @@
-
- - +
+ + + + +
- -
- +
+ +
+ + + + +
+
+ + + +
+
- -
- - - - -
-
- - -
-
-
-
-
Local save
-

Works are durable collection works; island placements are temporary exhibition objects. Export JSON if you want to move or test a local world.

-
- - - - - - - - - -
-
- -
- -
-
Phase 5 guardrails
-

Local pre-server checks for object volume, orphaned placements, terrain compatibility, hidden objects, and report logs.

-
-
- - - -
-
-
@@ -245,6 +217,17 @@
+ +