diff --git a/README.md b/README.md index 2f1b082..4bd97a0 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,17 @@ # Pixel Island Summoner -Local prototype. Open `index.html` with Live Server, or run: +Local Python rewrite of the original JavaScript browser prototype. + +## Run ```bash -python3 -m http.server 5500 +python app.py ``` -Then open `http://localhost:5500`. - -## Current controls - -- Open Pixel Studio with the large `+` button. -- Left drag on the pixel canvas: draw. -- Right drag or middle drag on the pixel canvas: pan. -- Mouse wheel on the pixel canvas: zoom. -- Use `Light` to paint light cells with the selected palette color. -- Use `Set Outline` to apply the selected palette color as a thin sprite outline. -- Click map objects to select them and show their bubble. +The app uses Python's built-in Tkinter UI toolkit, so there are no third-party dependencies. ## Notes -- Data is saved in `localStorage`. -- The vote bubble is local only. -- Server-side moderation is not implemented yet; see the TODO comment in `app.js` near `voteSelected()`. +- Local data is saved to `pixel_island_save.json`. +- You can draw pixel assets, save them to the library, summon them onto the island, erase placed objects, and export or import JSON save data. +- Mouse wheel zooms the island. In Pan / Inspect mode, drag the island to move the camera. diff --git a/app.js b/app.js index e4926c1..33389ba 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,8 @@ console.info('Pixel Island Summoner loaded'); - const STORAGE_KEY = 'pixel-island-summoner'; + const STORAGE_KEY = 'pixel-island-summoner:reset'; + const SAVE_SCHEMA = 1; const WORLD_W = 144; const WORLD_H = 112; const TILE_W = 32; @@ -19,10 +20,6 @@ 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 Lighting = Modules.Lighting; - const RenderPipeline = Modules.RenderPipeline; - const EditorActions = Modules.EditorActions; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); @@ -74,7 +71,6 @@ drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'), zoomOut: $('zoomOut'), zoomIn: $('zoomIn'), resetView: $('resetView'), drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'), - settingsToggle: $('settingsToggle'), settingsPanel: $('settingsPanel'), lightingToggle: $('lightingToggle'), assetName: $('assetName'), assetSize: $('assetSize'), @@ -85,13 +81,11 @@ sideSwitcher: $('sideSwitcher'), editRight: $('editRight'), editLeft: $('editLeft'), paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), - toolFill: $('toolFill'), toolPick: $('toolPick'), toolLight: $('toolLight'), toolDoor: $('toolDoor'), clearPaint: $('clearPaint'), - undoPaint: $('undoPaint'), redoPaint: $('redoPaint'), mirrorLeft: $('mirrorLeft'), + toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), - setOutline: $('setOutline'), clearOutline: $('clearOutline'), outlineStatus: $('outlineStatus'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'), - lineageNote: $('lineageNote'), assetList: $('assetList'), + lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox') }; @@ -107,14 +101,6 @@ let world = makeWorld(); let terrainCache = buildTerrainCache(world); let state = loadState(); - const appState = { - get world() { return world; }, - get view() { return view; }, - get settings() { - state.settings ||= {}; - return state.settings; - } - }; let selectedAssetId = state.assets[0]?.id ?? null; let mode = 'inspect'; let view = { x: 0, y: 0, zoom: 1 }; @@ -128,7 +114,6 @@ let lastRuntimeUpdate = performance.now(); let lastClockSecond = -1; let toastTimer = null; - let saveTimer = null; let editorSize = 8; let editorPixels = blankPixels(8); @@ -136,7 +121,8 @@ let editingSide = 'right'; let paintTool = 'brush'; let selectedColorCode = 'a'; - let outlineColorCode = null; + let advancedDraw = false; + let depthPixels = blankPixels(8).map(() => 0); let isPainting = false; let staticKind = 'nature'; let dynamicKind = 'human'; @@ -151,15 +137,11 @@ let confettiParticles = []; let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 }; let shadowCanvasCache = new WeakMap(); - let spriteShadeCache = new WeakMap(); 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 editorUndoStack = []; - let editorRedoStack = []; - let editorStrokeSnapshot = null; - let lastPointerPaintTime = 0; function bootstrap() { resizeCanvas(); @@ -167,7 +149,6 @@ hydrateRuntime(); wireUI(); renderPalette(); - updateOutlineStatus(); hydrateAuthorUI(); refreshCategoryUI(); setupEditor(8, blankPixels(8), blankPixels(8)); @@ -184,13 +165,6 @@ els.openEditor.addEventListener('click', () => setDrawerOpen(true)); els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); - els.settingsToggle?.addEventListener('click', () => { - if (els.settingsPanel) els.settingsPanel.hidden = !els.settingsPanel.hidden; - }); - els.lightingToggle?.addEventListener('change', () => { - appState.settings.lightingEnabled = els.lightingToggle.checked; - saveState(); - }); els.tabs.forEach((button) => { button.addEventListener('click', () => setTab(button.dataset.tab)); @@ -198,7 +172,7 @@ els.authorName?.addEventListener('input', () => { state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist'; - scheduleSaveState(); + saveState(); renderLibrary(); }); @@ -221,6 +195,7 @@ const nextSize = Number(els.assetSize.value); setupEditor(nextSize, resizePixels(getActiveEditorPixels(), editorSize, nextSize), resizePixels(editorLeftPixels, editorSize, nextSize)); doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) }; + depthPixels = resizeDepthPixels(depthPixels, editorSize, nextSize); lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize); resetEditorView(); drawEditor(); @@ -248,38 +223,25 @@ 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.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', () => { - applyEditorMutation(() => { - if (editingSide === 'left') editorLeftPixels = blankPixels(editorSize); - else editorPixels = blankPixels(editorSize); - clearLightsForActiveSide(); - drawEditor(); - }); + if (editingSide === 'left') editorLeftPixels = blankPixels(editorSize); + else editorPixels = blankPixels(editorSize); + depthPixels = blankPixels(editorSize).map(() => 0); + lightPixels = []; + drawEditor(); }); - els.setOutline?.addEventListener('click', () => { - applyEditorMutation(() => { - outlineColorCode = selectedColorCode; - updateOutlineStatus(); - clearSpriteCaches(); - drawEditor(); - }); - }); - els.clearOutline?.addEventListener('click', () => { - applyEditorMutation(() => { - outlineColorCode = null; - updateOutlineStatus(); - clearSpriteCaches(); - drawEditor(); - }); - }); - els.undoPaint?.addEventListener('click', undoEditor); - els.redoPaint?.addEventListener('click', redoEditor); - els.mirrorLeft?.addEventListener('click', mirrorLeftFromRight); - window.addEventListener('keydown', onEditorKeyDown); els.voteUp?.addEventListener('click', () => voteSelected(1)); els.voteDown?.addEventListener('click', () => voteSelected(-1)); els.bubbleRemix?.addEventListener('click', () => remixSelected()); @@ -294,7 +256,7 @@ window.addEventListener('mouseup', onPaintMouseUp); els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false }); els.paintCanvas.addEventListener('click', onPaintClick); - window.addEventListener('pointerup', () => { isPainting = false; commitEditorStroke(); lastPaintedKey = ''; editorPointer.panning = false; }); + window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; }); els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault()); els.saveAsset.addEventListener('click', saveAssetFromEditor); @@ -303,6 +265,10 @@ els.exportData.addEventListener('click', exportData); els.importData.addEventListener('click', importData); els.resetAll.addEventListener('click', resetAll); + els.showHiddenAssets?.addEventListener('click', () => { + els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden; + renderHiddenAssets(); + }); } function resizeCanvas() { @@ -345,24 +311,6 @@ els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`)); } - function onEditorKeyDown(event) { - if (!els.drawer?.classList.contains('open')) return; - const target = event.target; - if (target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return; - const key = event.key.toLowerCase(); - 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 (key === 'b') setPaintTool('brush'); - else if (key === 'e') setPaintTool('erase'); - else if (key === 'f') setPaintTool('fill'); - else if (key === 'i') setPaintTool('pick'); - } - function setMode(nextMode) { mode = nextMode; const isInspect = mode === 'inspect'; @@ -386,11 +334,12 @@ toggleHidden(els.sideSwitcher, isStatic); toggleHidden(els.staticSettingsPanel, !isStatic); toggleHidden(els.dynamicSettingsPanel, true); - toggleHidden(els.toolLight, false); + toggleHidden(els.toolLight, !advancedDraw); + toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.toolDoor, role !== 'building'); - toggleHidden(els.mirrorLeft, isStatic); 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'; @@ -405,42 +354,40 @@ function updateRoleHint() { if (!els.roleHint) return; const role = currentRole(); - const cleanTextMap = { - human: 'Humans use Right and Left sprites. They hop often and visit buildings.', - animal: 'Animals use Right and Left sprites. They hop often and prefer nature.', - nature: 'Nature attracts animals and birds. Static sprites are drawn at 2x scale.', + 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 2x scale.' + other: 'Other objects are neutral scenery and render at 2× scale.' }; - els.roleHint.textContent = cleanTextMap[role] || cleanTextMap.other; + els.roleHint.textContent = textMap[role] || textMap.other; } function updateSettingsSummary() { if (!els.settingsSummary) return; const role = currentRole(); - const isStaticRole = roleToCategory(role) === 'static'; - if (isStaticRole) { + 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)} / 2x static pixels / ${lightText}${doorText}.`; + els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`; } else { const hasLeft = hasAnyPixel(editorLeftPixels); - els.settingsSummary.textContent = `${cap(role)} / Right first / Left ${hasLeft ? 'ready' : 'auto-mirror suggested'}.`; + els.settingsSummary.textContent = `${cap(role)} / ▶ Right first / ◀ Left ${hasLeft ? 'ready' : 'auto-mirror suggested'}.`; } } function setPaintTool(tool) { paintTool = tool; - [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLight, els.toolDoor].filter(Boolean).forEach((button) => button.classList.remove('active')); - ({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, light: els.toolLight, door: els.toolDoor }[tool])?.classList.add('active'); + [els.toolBrush, els.toolErase, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active')); + ({ brush: els.toolBrush, erase: els.toolErase, 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 connected pixels with the selected palette color.', - pick: 'Pick a pixel color from the canvas.', 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.' + 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]; } @@ -771,17 +718,14 @@ function onPaintPointerDown(event) { event.preventDefault(); - lastPointerPaintTime = performance.now(); 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; - editorStrokeSnapshot = isPainting ? getEditorSnapshot() : null; mousePaint.active = false; mousePaint.panning = false; if (isPainting) paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey); - if (paintTool === 'fill' || paintTool === 'pick') isPainting = false; } function onPaintPointerMove(event) { @@ -808,7 +752,6 @@ function onPaintPointerUp(event) { if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false; isPainting = false; - commitEditorStroke(); lastPaintedKey = ''; } @@ -816,9 +759,7 @@ if (event.button !== 0) return; event.preventDefault(); lastPaintedKey = ''; - if (!editorStrokeSnapshot) editorStrokeSnapshot = getEditorSnapshot(); paintAtClient(event.clientX, event.clientY, 0, event.shiftKey); - commitEditorStroke(); } function onPaintWheel(event) { @@ -840,15 +781,12 @@ function onPaintMouseDown(event) { // Fallback for browsers/extensions where pointer events are swallowed. - if (performance.now() - lastPointerPaintTime < 500) return; 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 }; - editorStrokeSnapshot = mousePaint.active ? getEditorSnapshot() : null; if (mousePaint.active) paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey); - if (paintTool === 'fill' || paintTool === 'pick') mousePaint.active = false; } function onPaintMouseMove(event) { @@ -872,10 +810,13 @@ function onPaintMouseUp() { mousePaint.active = false; mousePaint.panning = false; - commitEditorStroke(); lastPaintedKey = ''; } + 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); @@ -887,34 +828,33 @@ const pixels = getActiveEditorPixels(); const index = y * editorSize + x; - let changed = false; if (paintTool === 'brush') { - changed = pixels[index] !== selectedColorCode; pixels[index] = selectedColorCode; } else if (paintTool === 'erase') { - changed = pixels[index] !== null || hasLightPixel(x, y); pixels[index] = null; removeLightPixel(x, y); - } else if (paintTool === 'fill') { - changed = fillPixels(x, y, selectedColorCode); - } else if (paintTool === 'pick') { - if (pixels[index]) selectPaletteColor(pixels[index]); - return; } else if (paintTool === 'light') { - changed = true; if (shiftKey || button === 2) removeLightPixel(x, y); else addLightPixel(x, y, selectedColorCode); updateSettingsSummary(); + } else if (paintTool === 'depth') { + depthPixels[index] = (shiftKey || button === 2) ? 0 : 1; } else if (paintTool === 'door') { if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') { - changed = doorPixel.x !== x || doorPixel.y !== y; doorPixel = { x, y }; updateSettingsSummary(); } } - if (changed) { - drawEditor(); - } + 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) { @@ -923,10 +863,9 @@ 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 + x: (sx - editorView.x) / editorView.zoom, + y: (sy - editorView.y) / editorView.zoom }; } @@ -934,15 +873,15 @@ return editingSide === 'left' ? editorLeftPixels : editorPixels; } - function setupEditor(size, rightPixels, leftPixels) { + function setupEditor(size, rightPixels, leftPixels, nextDepthPixels = null) { editorSize = size; editorPixels = normalizePixels(rightPixels, size); editorLeftPixels = normalizePixels(leftPixels, size); + depthPixels = Array.isArray(nextDepthPixels) ? 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(); - resetEditorHistory(); drawEditor(); } @@ -969,27 +908,20 @@ } } - if (outlineColorCode) { - const pixelsForOutline = getActiveEditorPixels(); - pctx.fillStyle = colorToHex(outlineColorCode); - for (let y = 0; y < editorSize; y++) { - for (let x = 0; x < editorSize; x++) { - if (!pixelsForOutline[y * editorSize + x]) continue; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { - const nx = x + dx, ny = y + dy; - if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize || !pixelsForOutline[ny * editorSize + nx]) { - pctx.fillRect((x + dx * 0.18) * cell, (y + dy * 0.18) * 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); } - // Redraw pixels over the outline preview. for (let y = 0; y < editorSize; y++) { for (let x = 0; x < editorSize; x++) { - const color = pixelsForOutline[y * editorSize + x]; - if (!color) continue; - pctx.fillStyle = colorToHex(color); - pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell)); + const depth = depthPixels[y * editorSize + 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)); } } } @@ -1051,122 +983,54 @@ lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); } - function hasLightPixel(x, y) { - return lightPixels.some((p) => p.x === x && p.y === y); - } - function clearLightsForActiveSide() { - if (editingSide === 'right') lightPixels = []; - } - - function fillPixels(startX, startY, colorCode) { - const pixels = getActiveEditorPixels(); - const target = pixels[startY * editorSize + startX] || null; - if (target === colorCode) return false; - const queue = [[startX, startY]]; - let changed = false; - while (queue.length) { - const [x, y] = queue.pop(); - if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) continue; - const index = y * editorSize + x; - if ((pixels[index] || null) !== target) continue; - pixels[index] = colorCode; - changed = true; - queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]); - } - return changed; - } - - function getEditorSnapshot() { + function alignEditorStateToBottom(size) { + const shift = getBottomShift(editorPixels, size); return { - right: editorPixels.slice(), - left: editorLeftPixels.slice(), - lights: lightPixels.map((p) => ({ ...p })), - outline: outlineColorCode, - door: { ...doorPixel }, - side: editingSide + rightPixels: shiftPixelsVertical(editorPixels, size, shift), + leftPixels: shiftPixelsVertical(editorLeftPixels, 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 restoreEditorSnapshot(snapshot) { - editorPixels = snapshot.right.slice(); - editorLeftPixels = snapshot.left.slice(); - lightPixels = snapshot.lights.map((p) => ({ ...p })); - outlineColorCode = snapshot.outline; - doorPixel = { ...snapshot.door }; - editingSide = snapshot.side; - updateOutlineStatus(); - updateSideButtons(); - updateSettingsSummary(); - clearSpriteCaches(); - drawEditor(); - } - - function snapshotsEqual(a, b) { - return JSON.stringify(a) === JSON.stringify(b); - } - - function pushEditorHistory(snapshot = getEditorSnapshot()) { - editorUndoStack.push(snapshot); - if (editorUndoStack.length > 80) editorUndoStack.shift(); - editorRedoStack = []; - updateHistoryButtons(); - } - - function applyEditorMutation(mutate) { - if (EditorActions?.apply) EditorActions.apply(getEditorSnapshot, pushEditorHistory, mutate); - else { - pushEditorHistory(); - mutate(); + 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 commitEditorStroke() { - if (!editorStrokeSnapshot) return; - const before = editorStrokeSnapshot; - editorStrokeSnapshot = null; - if (!snapshotsEqual(before, getEditorSnapshot())) pushEditorHistory(before); + 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 undoEditor() { - if (!editorUndoStack.length) return; - const current = getEditorSnapshot(); - const previous = editorUndoStack.pop(); - editorRedoStack.push(current); - restoreEditorSnapshot(previous); - updateHistoryButtons(); - } - - function redoEditor() { - if (!editorRedoStack.length) return; - const current = getEditorSnapshot(); - const next = editorRedoStack.pop(); - editorUndoStack.push(current); - restoreEditorSnapshot(next); - updateHistoryButtons(); - } - - function updateHistoryButtons() { - if (els.undoPaint) els.undoPaint.disabled = editorUndoStack.length === 0; - if (els.redoPaint) els.redoPaint.disabled = editorRedoStack.length === 0; - } - - function resetEditorHistory() { - editorUndoStack = []; - editorRedoStack = []; - editorStrokeSnapshot = null; - updateHistoryButtons(); - } - - function mirrorLeftFromRight() { - if (roleToCategory(currentRole()) !== 'dynamic') return; - applyEditorMutation(() => { - editorLeftPixels = mirrorPixels(editorPixels, editorSize); - editingSide = 'left'; - updateSideButtons(); - drawEditor(); - }); - toast('Left sprite mirrored from Right.'); + 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() { @@ -1181,6 +1045,7 @@ 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, @@ -1192,17 +1057,17 @@ author: existing?.author || state.authorName || 'Local Artist', parentAssetId: existing ? existing.parentAssetId : editParentId, originalAssetId: existing ? existing.originalAssetId : editOriginalId, - pixels: encodePixels(editorPixels), + pixels: encodePixels(aligned.rightPixels), faces: category === 'dynamic' ? { - right: encodePixels(editorPixels), - left: encodePixels(hasAnyPixel(editorLeftPixels) ? editorLeftPixels : mirrorPixels(editorPixels, size)) + right: encodePixels(aligned.rightPixels), + left: encodePixels(hasAnyPixel(aligned.leftPixels) ? aligned.leftPixels : mirrorPixels(aligned.rightPixels, size)) } : null, meta: { - hasLight: lightPixels.length > 0, - lightPixels: lightPixels.map((p) => ({ x: Math.floor(p.x), y: Math.floor(p.y), c: p.c || selectedColorCode })), + hasLight: aligned.lightPixels.length > 0, + lightPixels: aligned.lightPixels.map((p) => ({ x: Math.floor(p.x), y: Math.floor(p.y), c: p.c || selectedColorCode })), lightColor: selectedColorCode, - outlineColor: outlineColorCode, - door: category === 'static' && subtype === 'building' ? { ...doorPixel } : null + depthPixels: encodeDepthPixels(aligned.depthPixels), + door: category === 'static' && subtype === 'building' ? aligned.door : null } }; if (existing) { @@ -1219,7 +1084,7 @@ editingAssetId = null; editingAssetId = asset.id; saveState(); - clearSpriteCaches(); + spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); @@ -1245,8 +1110,7 @@ dynamicKind = 'human'; editingSide = 'right'; lightPixels = []; - outlineColorCode = null; - updateOutlineStatus(); + depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; setupEditor(8, blankPixels(8), blankPixels(8)); refreshCategoryUI(); @@ -1257,11 +1121,6 @@ function hydrateAuthorUI() { state.authorName = state.authorName || 'Local Artist'; if (els.authorName) els.authorName.value = state.authorName; - renderSettingsUI(); - } - - function renderSettingsUI() { - if (els.lightingToggle) els.lightingToggle.checked = appState.settings.lightingEnabled !== false; } function focusAssetInWorld(asset) { @@ -1285,60 +1144,141 @@ function renderLibrary() { els.assetList.innerHTML = ''; - if (!state.assets.length) { - els.assetList.textContent = 'No assets yet.'; + const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]); + if (!visibleAssets.length) { + els.assetList.textContent = 'No visible assets.'; + renderHiddenAssets(); return; } - for (const asset of state.assets) { - if (state.hiddenAssets?.[asset.id]) continue; - const card = document.createElement('article'); - card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`; + 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'; - const lineage = asset.parentAssetId ? ' / derivative' : ''; - meta.innerHTML = `${displayCategory(asset)} / ${asset.size}x${asset.size}${lineage}`; + meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`; 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(`Up ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); - const down = makeButton(`Down ${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 restore = makeButton('Show again', (event) => { + event.stopPropagation(); + delete state.hiddenAssets[asset.id]; + saveState(); + renderLibrary(); + toast('Asset shown again.'); }); - const del = makeButton('Delete', (event) => { - event?.stopPropagation?.(); - deleteAsset(asset); - }); - del.classList.add('danger'); - actions.append(up, down, hide, copy, del); + actions.append(restore); meta.append(actions); - card.append(preview, meta); - els.assetList.append(card); + row.append(preview, meta); + els.hiddenAssetList.append(row); } } @@ -1373,43 +1313,18 @@ swatch.title = `${entry.code} ${entry.color}`; swatch.style.background = entry.color; swatch.addEventListener('click', () => { - selectPaletteColor(entry.code); + selectedColorCode = entry.code; + els.paintColor.value = entry.color; + renderPalette(); + drawEditor(); }); els.paletteGrid.append(swatch); } els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b'; } - function selectPaletteColor(code) { - if (!PALETTE_BY_CODE[code]) return; - selectedColorCode = code; - els.paintColor.value = PALETTE_BY_CODE[code]; - renderPalette(); - drawEditor(); - } - - function updateOutlineStatus() { - if (!els.outlineStatus) return; - els.outlineStatus.textContent = outlineColorCode ? `Outline: ${outlineColorCode}` : 'Outline: none'; - els.outlineStatus.style.setProperty('--outline-swatch', outlineColorCode ? colorToHex(outlineColorCode) : 'transparent'); - } - function displayCategory(asset) { - return roleLabelForAsset(asset); - } - - function roleLabelForAsset(asset) { - const subtype = asset?.subtype || 'other'; - return { - human: 'Human', - animal: 'Animal', - fish: 'Fish', - bird: 'Bird', - nature: 'Nature', - building: 'Building', - water: 'Water', - other: 'Other' - }[subtype] || cap(subtype); + return asset.category === 'dynamic' ? 'People & Animals' : 'Buildings & Nature'; } function makeButton(label, onClick) { @@ -1435,10 +1350,9 @@ const right = asset.faces?.right || asset.pixels || blankPixels(asset.size); const left = asset.faces?.left || mirrorPixels(right, asset.size); lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); - outlineColorCode = asset.meta?.outlineColor || null; - updateOutlineStatus(); + 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, left); + setupEditor(asset.size, right, left, depthPixels); refreshCategoryUI(); els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`; } @@ -1451,7 +1365,7 @@ state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id); if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null; saveState(); - clearSpriteCaches(); + spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); @@ -1460,7 +1374,7 @@ function updateSelectedLabel() { if (!els.selectedAssetName) return; const asset = findAsset(selectedAssetId); - els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)})` : 'Selected: none'; + els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none'; } function hydrateRuntime() { @@ -1521,14 +1435,13 @@ spawnFishBubbleCluster(item.x, item.y, time, item.seed); item.nextBubbleAt = time + 1100 + Math.random() * 1800; } - const delta = { x: item.targetX - item.x, y: item.targetY - item.y }; - const distance = Modules.Vec2?.length ? Modules.Vec2.length(delta) : Math.hypot(delta.x, delta.y); + 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 = delta.x; - const dy = delta.y; - const len = distance || 1; + 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; @@ -1589,38 +1502,24 @@ } function getShadowForMinute(minute) { - if (Lighting?.getShadowForMinute) return Lighting.getShadowForMinute(minute); - const light = getCelestialLightForMinute(minute); - const elevation = light.elevation; - const length = lerp(light.isNight ? 1.35 : 1.9, light.isNight ? .85 : .52, 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, elevation) - }; - } - - function getCelestialLightForMinute(minute) { - if (Lighting?.getCelestialLightForMinute) return Lighting.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; + 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 { - x: sourceX, - y: sourceY, - elevation, - isNight, - shadeAlpha: isNight ? .18 : lerp(.20, .08, elevation) + dirX, + length, + skewX: dirX * (0.72 + 0.32 * length), + scaleY: 0.24 + 0.06 * length, + alpha: isNight ? .12 : lerp(.28, .16, elevation) }; } function getPhase() { - if (Lighting?.getPhase) return Lighting.getPhase(DAY_MS); const t = mod(Date.now(), DAY_MS); const minute = t / 60000; const stops = [ @@ -1645,7 +1544,6 @@ 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)})`, - light: getCelestialLightForMinute(minute), shadow: getShadowForMinute(minute) }; } @@ -1656,62 +1554,33 @@ ctx.fillStyle = '#86d5ff'; ctx.fillRect(0, 0, cw, ch); const phase = getPhase(); - const lightingEnabled = appState.settings.lightingEnabled !== false; renderPhase = phase; ctx.save(); ctx.translate(view.x, view.y); ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; + ctx.drawImage(terrainCache.canvas, 0, 0); + drawHoverTile(); const lightSources = []; - const renderContext = { time, phase, lightingEnabled, lightSources }; - const stages = [ - drawTerrainStage, - drawTerrainShadeStage, - drawHoverStage, - drawObjectsStage, - drawEffectsStage - ]; - if (RenderPipeline?.run) RenderPipeline.run(stages, renderContext); - else stages.forEach((stage) => stage(renderContext)); + drawObjects(time, lightSources, phase); + drawBubbleParticles(time); + drawSpawnEffects(time); + drawConfettiParticles(time); ctx.restore(); updateSelectionBubble(time); - if (lightingEnabled && phase.tint !== 'rgba(255, 255, 255, 0)') { + if (phase.tint !== 'rgba(255, 255, 255, 0)') { ctx.fillStyle = phase.tint; ctx.fillRect(0, 0, cw, ch); } - if (lightingEnabled && phase.darkness > 0) { + if (phase.darkness > 0) { ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`; ctx.fillRect(0, 0, cw, ch); drawLightSources(lightSources, phase.darkness); } } - function drawTerrainStage() { - ctx.drawImage(terrainCache.canvas, 0, 0); - } - - function drawTerrainShadeStage({ phase, lightingEnabled }) { - if (lightingEnabled && view.zoom >= 0.78) drawTerrainShade(phase); - } - - function drawHoverStage() { - drawHoverTile(); - } - - function drawObjectsStage({ time, phase, lightingEnabled, lightSources }) { - drawObjects(time, lightSources, lightingEnabled ? phase : null); - } - - function drawEffectsStage({ time }) { - drawBubbleParticles(time); - if (view.zoom >= 0.7) { - drawSpawnEffects(time); - drawConfettiParticles(time); - } - } - function drawHoverTile() { if (!hoverTile) return; const { x, y } = tileToWorld(hoverTile.x, hoverTile.y); @@ -1727,45 +1596,6 @@ ctx.stroke(); } - function drawTerrainShade(phase) { - const light = phase?.light; - if (!light) return; - const alphaBase = light.shadeAlpha || .12; - const left = -view.x / view.zoom - TILE_W; - const top = -view.y / view.zoom - TILE_H * 3; - const right = left + cw / view.zoom + TILE_W * 2; - const bottom = top + ch / view.zoom + TILE_H * 5; - ctx.save(); - for (const tile of world.tiles) { - if (tile.type === 'water') continue; - const pos = tile.worldPos || tileToWorld(tile.x, tile.y); - if (pos.x < left || pos.x > right || pos.y < top || pos.y > bottom) continue; - const lift = getTileLift(tile); - const slope = clamp((tile.shade * .45) - light.x * .18 + light.y * .08, -.28, .32); - const shadeAlpha = clamp(alphaBase * (.55 + slope), 0, .22); - if (shadeAlpha > .012) { - ctx.fillStyle = `rgba(20, 30, 43, ${shadeAlpha.toFixed(3)})`; - drawTileDiamond(ctx, pos.x, pos.y - lift); - } - const warmAlpha = clamp((alphaBase * .7) * (.26 - slope), 0, .10); - if (!light.isNight && warmAlpha > .012) { - ctx.fillStyle = `rgba(255, 244, 194, ${warmAlpha.toFixed(3)})`; - drawTileDiamond(ctx, pos.x, pos.y - lift); - } - } - ctx.restore(); - } - - function drawTileDiamond(c, x, y) { - c.beginPath(); - c.moveTo(x, y); - c.lineTo(x + TILE_W / 2, y + TILE_H / 2); - c.lineTo(x, y + TILE_H); - c.lineTo(x - TILE_W / 2, y + TILE_H / 2); - c.closePath(); - c.fill(); - } - function drawSpawnEffects(time) { if (!spawnEffects.length) return; spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650); @@ -1791,7 +1621,7 @@ } function drawObjects(time, lightSources, phase) { - const items = getDrawableItems(time).filter(isDrawableItemVisible); + const items = getDrawableItems(time); for (const item of items) { if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { @@ -1805,14 +1635,6 @@ } } - function isDrawableItemVisible(item) { - const pos = tileToWorld(item.x, item.y); - const margin = (item.asset?.size || 16) * (item.asset?.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) + 36; - const sx = pos.x * view.zoom + view.x; - const sy = pos.y * view.zoom + view.y; - return sx > -margin && sy > -margin && sx < cw + margin && sy < ch + margin; - } - function getSpriteDrawInfo(item, time, includeSelectBounce = true) { const asset = item.asset; const pos = tileToWorld(item.x, item.y); @@ -1853,8 +1675,7 @@ function drawSpriteItem(item, time, lightSources, underwater, phase) { const { asset, pos, sprite, drawX, drawY, alpha, angle } = getSpriteDrawInfo(item, time, true); - const simpleZoom = view.zoom < 0.7; - if (!simpleZoom && !(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(pos, sprite, asset, phase); + if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); ctx.save(); ctx.globalAlpha = alpha; @@ -1862,10 +1683,8 @@ 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)); - if (!simpleZoom) drawSpriteShade(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), phase, asset); } else { ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY)); - if (!simpleZoom) drawSpriteShade(sprite, Math.round(drawX), Math.round(drawY), phase, asset); } if (underwater) { ctx.fillStyle = 'rgba(103, 181, 217, .18)'; @@ -1901,58 +1720,23 @@ - function drawSpriteShadow(pos, sprite, asset, phase) { - if (!phase) return; - const shadow = phase?.shadow || { x: 5, y: 4, alpha: .16, length: 1 }; + 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 angle = Math.atan2(shadow.y, shadow.x); + const contactX = drawX + sprite.width / 2; + const contactY = drawY + sprite.height; + ctx.save(); ctx.globalAlpha = shadow.alpha; - ctx.translate(pos.x + shadow.x, pos.y + TILE_H * .58 + shadow.y); - ctx.rotate(angle * .28); - ctx.transform(1 + shadow.length * .22, 0, -shadow.x * .018, -0.30, 0, 0); - ctx.drawImage(silhouette, Math.round(-sprite.width / 2), -sprite.height); + // 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 drawSpriteShade(sprite, x, y, phase, asset) { - const light = phase?.light; - if (!light || asset?.subtype === 'water') return; - const shadeAlpha = clamp((light.shadeAlpha || .12) * (asset?.category === 'dynamic' ? .78 : 1), .04, light.isNight ? .20 : .16); - const shade = getSpriteShadeCanvas(sprite, light.x >= 0 ? 'left' : 'right', Math.round(shadeAlpha * 100)); - ctx.drawImage(shade, x, y); - } - - function getSpriteShadeCanvas(sprite, side, alphaBucket) { - let cache = spriteShadeCache.get(sprite); - if (!cache) { - cache = new Map(); - spriteShadeCache.set(sprite, cache); - } - const key = `${side}:${alphaBucket}`; - if (cache.has(key)) return cache.get(key); - const canvas = document.createElement('canvas'); - canvas.width = sprite.width; - canvas.height = sprite.height; - const c = canvas.getContext('2d'); - c.imageSmoothingEnabled = false; - c.drawImage(sprite, 0, 0); - c.globalCompositeOperation = 'source-in'; - const alpha = alphaBucket / 100; - const gradient = c.createLinearGradient(0, 0, sprite.width, 0); - if (side === 'left') { - gradient.addColorStop(0, `rgba(16, 22, 34, ${alpha})`); - gradient.addColorStop(.68, 'rgba(16, 22, 34, 0)'); - } else { - gradient.addColorStop(.32, 'rgba(16, 22, 34, 0)'); - gradient.addColorStop(1, `rgba(16, 22, 34, ${alpha})`); - } - c.fillStyle = gradient; - c.fillRect(0, 0, sprite.width, sprite.height); - cache.set(key, canvas); - return canvas; - } - function getShadowCanvas(sprite) { if (shadowCanvasCache.has(sprite)) return shadowCanvasCache.get(sprite); const canvas = document.createElement('canvas'); @@ -1960,7 +1744,11 @@ 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); @@ -2224,27 +2012,11 @@ canvas.height = asset.size * scale; const c = canvas.getContext('2d'); c.imageSmoothingEnabled = false; - const outline = asset.meta?.outlineColor; - if (outline) { - c.fillStyle = colorToHex(outline); - const outlineReach = 0.2; - for (let y = 0; y < asset.size; y++) { - for (let x = 0; x < asset.size; x++) { - if (!pixels[y * asset.size + x]) continue; - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) { - const nx = x + dx, ny = y + dy; - if (nx < 0 || ny < 0 || nx >= asset.size || ny >= asset.size || !pixels[ny * asset.size + nx]) { - c.fillRect((x + dx * outlineReach) * scale, (y + dy * outlineReach) * scale, scale, scale); - } - } - } - } - } 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.fillStyle = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y)); c.fillRect(x * scale, y * scale, scale, scale); } } @@ -2252,12 +2024,6 @@ return canvas; } - function clearSpriteCaches() { - spriteCache.clear(); - spriteShadeCache = new WeakMap(); - shadowCanvasCache = new WeakMap(); - } - function getAssetPixels(asset, side = 'right') { if (asset.category === 'dynamic') { if (side === 'left') return normalizePixels(asset.faces?.left || mirrorPixels(normalizePixels(asset.faces?.right || asset.pixels || [], asset.size), asset.size), asset.size); @@ -2295,8 +2061,7 @@ 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; - const worldPos = tileToWorld(x, y); - tiles.push({ x, y, worldPos, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 }); + tiles.push({ x, y, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 }); } } return { @@ -2327,7 +2092,7 @@ } function drawTerrainTile(c, tile, worldData) { - const { x, y } = tile.worldPos || tileToWorld(tile.x, tile.y); + const { x, y } = tileToWorld(tile.x, tile.y); const palette = { water: ['#6ebfe0', '#69bbdc'], sand: ['#ead99d', '#e8d293'], @@ -2341,8 +2106,8 @@ if (tile.type === 'highland') { const frontLeft = worldData.get(tile.x, tile.y + 1); const frontRight = worldData.get(tile.x + 1, tile.y); - const leftLower = isVisibleHighlandFace(tile, frontLeft, 'left', worldData); - const rightLower = isVisibleHighlandFace(tile, frontRight, 'right', worldData); + const leftLower = !frontLeft || frontLeft.type !== 'highland'; + const rightLower = !frontRight || frontRight.type !== 'highland'; if (rightLower) { c.fillStyle = '#7b8e6c'; c.beginPath(); @@ -2385,7 +2150,7 @@ c.fill(); } - c.strokeStyle = getTerrainBorderColor(tile.type); + c.strokeStyle = 'rgba(36, 48, 68, .02)'; c.lineWidth = 1; c.beginPath(); c.moveTo(x, y - lift); @@ -2396,22 +2161,6 @@ c.stroke(); } - function getTerrainBorderColor(type) { - return { - water: '#69bbdc', - sand: '#d8c98f', - grass: '#7fbd70', - highland: '#879b72' - }[type] || '#7fbd70'; - } - - function isVisibleHighlandFace(tile, neighbor, side, worldData) { - if (neighbor?.type === 'highland') return false; - if (!neighbor || neighbor.type === 'water') return true; - const frontNeighbor = side === 'left' ? worldData.get(tile.x, tile.y + 2) : worldData.get(tile.x + 2, tile.y); - return !frontNeighbor || frontNeighbor.type !== 'highland'; - } - function getTileLift(tile) { return tile?.type === 'highland' ? 12 : 0; } @@ -2426,7 +2175,7 @@ const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); - return normalizeState(parsed); + if (parsed && parsed.schema === SAVE_SCHEMA && Array.isArray(parsed.assets)) return normalizeState(parsed); } } catch (error) { console.warn('Could not load local state.', error); @@ -2435,51 +2184,22 @@ } function saveState() { - clearTimeout(saveTimer); + state.schema = SAVE_SCHEMA; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } - function scheduleSaveState(delay = 350) { - clearTimeout(saveTimer); - saveTimer = setTimeout(saveState, delay); - } - function normalizeState(input) { - const fallback = seedState(); - if (!input || !Array.isArray(input.assets)) return fallback; - const normalized = { - version: 6, + return { + schema: SAVE_SCHEMA, authorName: input.authorName || 'Local Artist', - assets: input.assets.map(normalizeAsset), + assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [], placed: dedupeByAsset(Array.isArray(input.placed) ? input.placed : []), dynamicSummons: dedupeByAsset(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []), objectVotes: input.objectVotes || {}, assetVotes: input.assetVotes || {}, hiddenAssets: input.hiddenAssets || {}, - hiddenObjects: input.hiddenObjects || {}, - settings: { - lightingEnabled: input.settings?.lightingEnabled !== false - } + hiddenObjects: input.hiddenObjects || {} }; - normalized.placed = normalized.placed.map((placed) => { - const asset = normalized.assets.find((a) => a.id === placed.assetId); - if (!asset) return placed; - const tile = world.get(placed.x, placed.y); - if (asset.subtype === 'water' && tile?.type !== 'water') { - return { ...placed, ...findNearestTerrain('water', placed.x, placed.y) }; - } - return placed; - }); - normalized.dynamicSummons = normalized.dynamicSummons.map((summon) => { - const asset = normalized.assets.find((a) => a.id === summon.assetId); - if (!asset) return summon; - const tile = world.get(Math.round(summon.homeX), Math.round(summon.homeY)); - if (asset.subtype === 'fish' && tile?.type !== 'water') { - return { ...summon, ...homeFromPos(findNearestTerrain('water', Math.round(summon.homeX), Math.round(summon.homeY))) }; - } - return summon; - }); - return normalized; } function normalizeAsset(asset) { @@ -2504,7 +2224,7 @@ hasLight: Boolean(asset.meta?.hasLight || (asset.meta?.lightPixels || []).length), lightPixels: Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels.map((p) => ({ x: Number(p.x), y: Number(p.y), c: p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a') })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : [], lightColor: nearestPaletteCode(asset.meta?.lightColor || '#ffd86a'), - outlineColor: asset.meta?.outlineColor ? nearestPaletteCode(asset.meta.outlineColor) : null, + depthPixels: encodeDepthPixels(normalizeDepthPixels(asset.meta?.depthPixels || [], size)), door: asset.meta?.door || null } }; @@ -2553,7 +2273,7 @@ const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id])); return { - version: 6, + schema: SAVE_SCHEMA, authorName: 'Local Artist', assets, placed: [ @@ -2565,7 +2285,6 @@ assetVotes: {}, hiddenAssets: {}, hiddenObjects: {}, - settings: { lightingEnabled: true }, 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() }, @@ -2596,7 +2315,7 @@ hasLight: Boolean(meta.hasLight), lightPixels: (meta.lightPixels || []).map((p) => ({ ...p, c: p.c || nearestPaletteCode(meta.lightColor || '#ffd86a') })), lightColor: nearestPaletteCode(meta.lightColor || '#ffd86a'), - outlineColor: meta.outlineColor ? nearestPaletteCode(meta.outlineColor) : null, + depthPixels: encodeDepthPixels(normalizeDepthPixels(meta.depthPixels || [], size)), door: meta.door || null } }; @@ -2635,7 +2354,7 @@ hydrateAuthorUI(); selectedAssetId = state.assets[0]?.id ?? null; saveState(); - clearSpriteCaches(); + spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); @@ -2653,7 +2372,7 @@ hydrateAuthorUI(); selectedAssetId = state.assets[0]?.id ?? null; saveState(); - clearSpriteCaches(); + spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); @@ -2728,6 +2447,53 @@ } + + 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) { + const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1); + return depth[y * asset.size + x] || 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); diff --git a/index.html b/index.html index 3ef0570..186d8af 100644 --- a/index.html +++ b/index.html @@ -33,26 +33,18 @@
- + - -Make static scenery or moving island life.