From 94571943cf4f70cbabad1488d06559df80b166f8 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 2 Jun 2026 17:19:36 +0900 Subject: [PATCH] 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; -}