(() => { 'use strict'; console.info('Pixel Island Summoner loaded'); const STORAGE_KEY = 'pixel-island-summoner:phase5c'; const SAVE_SCHEMA = 9; 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 PHASE5_GUARDRAILS = { maxAssets: 160, maxWorldObjects: 220, maxReports: 200 }; 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 defaultVisualSettings() { return { enableLights: true, enableParticles: true, enableDayNight: true }; } function visualSettings() { state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; return state.settings; } function hydrateVisualSettingsUI() { const settings = visualSettings(); if (els.settingLights) els.settingLights.checked = settings.enableLights !== false; if (els.settingParticles) els.settingParticles.checked = settings.enableParticles !== false; if (els.settingDayNight) els.settingDayNight.checked = settings.enableDayNight !== false; } function clearVisualEffectState() { spawnEffects = []; bubbleParticles = []; confettiParticles = []; landStepParticles = []; natureDriftParticles = []; } 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', 'ship', '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'), bubbleReport: $('bubbleReport'), 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'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'), 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'), guardrailStats: $('guardrailStats'), validateWorld: $('validateWorld'), exportModerationReport: $('exportModerationReport'), clearReports: $('clearReports'), settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName') }; 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, button: 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 depthPaintMode = 1; 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 landStepParticles = []; let natureDriftParticles = []; let coastalFoamTextures = null; 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 pendingReport = 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(); coastalFoamTextures = buildCoastalFoamTextures(); wireUI(); renderPalette(); hydrateAuthorUI(); refreshCategoryUI(); setupEditor(8, blankPixels(8), null); clearEditorHistory(); renderLibrary(); updateSelectedLabel(); rebuildWorldIndex(); updateSyncStats(); updateGuardrailStats(); hydrateVisualSettingsUI(); 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.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; saveState(); render(); }); els.settingParticles?.addEventListener('change', () => { visualSettings().enableParticles = !!els.settingParticles.checked; if (!visualSettings().enableParticles) clearVisualEffectState(); saveState(); render(); }); els.settingDayNight?.addEventListener('change', () => { visualSettings().enableDayNight = !!els.settingDayNight.checked; spriteCache.clear(); saveState(); updateClock(); render(); }); [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.depthHigh?.addEventListener('click', () => setDepthPaintMode(1)); els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1)); els.depthClear?.addEventListener('click', () => setDepthPaintMode(0)); els.toggleAdvanced?.addEventListener('click', () => { advancedDraw = !advancedDraw; toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.depthClear, !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.bubbleReport?.addEventListener('click', () => openReportDialog()); els.reportCancel?.addEventListener('click', () => closeReportDialog()); els.reportSubmit?.addEventListener('click', () => submitReportDialog()); els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); }); 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.validateWorld?.addEventListener('click', validateWorld); els.exportModerationReport?.addEventListener('click', exportModerationReport); els.clearReports?.addEventListener('click', clearModerationReports); 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.', ship: 'Ships are static water objects. They float and emit ring ripples.', 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 high or low depth. Use High/Low/Clear buttons; Shift clears.' }; els.editHint.textContent = hints[tool] || hints.brush; [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); } function setDepthPaintMode(mode) { depthPaintMode = mode === -1 ? -1 : mode === 0 ? 0 : 1; [els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active')); ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active'); if (paintTool !== 'depth') setPaintTool('depth'); const label = depthPaintMode > 0 ? 'high' : depthPaintMode < 0 ? 'low' : 'clear'; if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Left-drag paints this depth value.`; } 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(); if (event.button === 2) { clearWorldSelection(false); pointer.down = false; pointer.id = null; return; } 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(), button: event.button || 0 }; 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); if (isSpritePixelHit(item.asset, info, worldX, worldY)) { return { kind: item.kind, id: item.source.id, assetId: item.asset.id }; } } return null; } function isSpritePixelHit(asset, info, worldX, worldY) { const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; let localX; let localY; if (info.angle) { const pivotX = info.drawX + info.sprite.width / 2; const pivotY = info.drawY + info.sprite.height * 0.8; const dx = worldX - pivotX; const dy = worldY - pivotY; const cos = Math.cos(-info.angle); const sin = Math.sin(-info.angle); localX = dx * cos - dy * sin + info.sprite.width / 2; localY = dx * sin + dy * cos + info.sprite.height * 0.8; } else { localX = worldX - info.drawX; localY = worldY - info.drawY; } if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false; const px = Math.floor(localX / scale); const py = Math.floor(localY / scale); if (px < 0 || py < 0 || px >= asset.size || py >= asset.size) return false; const pixels = getAssetPixels(asset, info.side || 'right'); return Boolean(pixels[py * asset.size + px]); } 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 clearWorldSelection(announce = false) { selectedObject = null; updateSelectionBubble(performance.now()); } function inspectAt(x, y) { const tile = world.get(x, y); const staticObjects = getPlacedAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]); // Object selection is now based on actual opaque sprite pixels only. // A plain tile click updates information but does not select hidden/transparent areas. 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 here: ${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; const existingObject = asset.category === 'static' ? state.placed.find((p) => p.assetId === asset.id) : state.dynamicSummons.find((p) => p.assetId === asset.id); const isNewObject = !existingObject; if (isNewObject && getWorldObjectCount() >= PHASE5_GUARDRAILS.maxWorldObjects) { toast(`World object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Remove objects before summoning more.`); 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' || asset.subtype === 'ship') && tile.type !== 'water') { toast('Water/ship 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' || asset.subtype === 'ship'))) { 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 usesRightButtonAsPaint() { return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; } function onPaintPointerDown(event) { event.preventDefault(); els.paintCanvas.setPointerCapture?.(event.pointerId); lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY, button: event.button || 0 }; 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) { const requiredButton = editorPointer?.button === 2 ? 2 : 1; if ((event.buttons & requiredButton) === 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 && !usesRightButtonAsPaint()); 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 ? 0 : (button === 2 ? -1 : depthPaintMode); 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 = () => { const targetSize = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height); commitEditorMutation(() => { const previousSize = editorSize; const canvas = document.createElement('canvas'); canvas.width = targetSize; canvas.height = targetSize; const c = canvas.getContext('2d', { willReadFrequently: true }); c.imageSmoothingEnabled = false; c.clearRect(0, 0, targetSize, targetSize); const fit = fitImageInsideSquare(img.naturalWidth || img.width, img.naturalHeight || img.height, targetSize); c.drawImage(img, fit.dx, fit.dy, fit.dw, fit.dh); const data = c.getImageData(0, 0, targetSize, targetSize).data; const next = blankPixels(targetSize); for (let i = 0; i < targetSize * targetSize; i++) { const alpha = data[i * 4 + 3]; if (alpha < 32) continue; const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]); next[i] = nearestPaletteCode(hex); } editorSize = targetSize; els.assetSize.value = String(targetSize); setDisplayEditorPixels(next); depthPixels = blankPixels(targetSize).map(() => 0); lightPixels = []; doorPixel = { x: Math.min(doorPixel.x, targetSize - 1), y: Math.min(doorPixel.y, targetSize - 1) }; editorSelection = null; if (previousSize !== targetSize) resetEditorView(); return true; }); URL.revokeObjectURL(img.src); const note = (img.naturalWidth === targetSize && img.naturalHeight === targetSize) ? '' : ` (fit from ${img.naturalWidth}×${img.naturalHeight})`; toast(`PNG imported as ${targetSize}×${targetSize}${note}.`); }; img.onerror = () => toast('Could not read that PNG.'); img.src = URL.createObjectURL(file); } function chooseImportCanvasSize(width, height) { const allowed = [8, 16, 32, 64]; const w = Math.max(1, Math.round(Number(width) || 1)); const h = Math.max(1, Math.round(Number(height) || 1)); if (w === h && allowed.includes(w)) return w; const target = Math.max(w, h); return allowed.find((size) => target <= size) || allowed[allowed.length - 1]; } function fitImageInsideSquare(width, height, size) { const w = Math.max(1, Number(width) || 1); const h = Math.max(1, Number(height) || 1); const scale = Math.min(size / w, size / h); const dw = Math.max(1, Math.round(w * scale)); const dh = Math.max(1, Math.round(h * scale)); return { dx: Math.floor((size - dw) / 2), dy: Math.floor((size - dh) / 2), dw, dh }; } 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; const high = depth > 0; pctx.fillStyle = high ? 'rgba(34, 116, 255, .62)' : 'rgba(88, 44, 120, .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 = high ? 'rgba(6, 28, 75, .55)' : 'rgba(35, 13, 54, .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; } if (!existing && state.assets.length >= PHASE5_GUARDRAILS.maxAssets) { toast(`Asset limit reached (${PHASE5_GUARDRAILS.maxAssets}). Delete or hide unused assets before saving more.`); 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', 'ship', '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 move = 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.'); }); const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); actions.append(up, down, hide, move, copy, del); meta.append(actions); card.append(preview, meta); return card; } function renderHiddenAssets() { if (!els.hiddenAssetList) return; els.hiddenAssetList.innerHTML = ''; const hiddenAssets = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]); const hiddenObjects = collectHiddenObjects(); if (!hiddenAssets.length && !hiddenObjects.length) { els.hiddenAssetList.textContent = 'No hidden assets or objects.'; return; } if (hiddenObjects.length) { const title = document.createElement('div'); title.className = 'hiddenSectionTitle'; title.textContent = 'Hidden map objects'; els.hiddenAssetList.append(title); for (const entry of hiddenObjects) els.hiddenAssetList.append(makeHiddenObjectCard(entry)); } if (hiddenAssets.length) { const title = document.createElement('div'); title.className = 'hiddenSectionTitle'; title.textContent = 'Hidden library assets'; els.hiddenAssetList.append(title); for (const asset of hiddenAssets) els.hiddenAssetList.append(makeHiddenAssetCard(asset)); } } function collectHiddenObjects() { const out = []; const reportsByObject = new Map((state.moderationReports || []).map((report) => [report.objectId, report])); for (const placed of state.placed || []) { if (!state.hiddenObjects?.[placed.id]) continue; const asset = findAsset(placed.assetId); if (!asset) continue; out.push({ kind: 'static', object: placed, asset, report: reportsByObject.get(placed.id) || null }); } for (const summon of state.dynamicSummons || []) { if (!state.hiddenObjects?.[summon.id]) continue; const asset = findAsset(summon.assetId); if (!asset) continue; out.push({ kind: 'dynamic', object: summon, asset, report: reportsByObject.get(summon.id) || null }); } return out; } function makeHiddenObjectCard(entry) { const { kind, object, asset, report } = entry; 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 location = kind === 'dynamic' ? `home ${Math.round(object.homeX)},${Math.round(object.homeY)}` : `tile ${object.x},${object.y}`; meta.innerHTML = ``; meta.querySelector('strong').textContent = asset.name; meta.querySelectorAll('span')[0].textContent = `${cap(kind)} object / ${location}`; meta.querySelectorAll('span')[1].textContent = report ? `Reported: ${report.reason}` : 'Hidden locally'; const actions = document.createElement('div'); actions.className = 'assetActions'; const restore = makeButton('Show again', (event) => { event.stopPropagation(); delete state.hiddenObjects[object.id]; saveState(); hydrateRuntime(); renderLibrary(); updateSelectionBubble(performance.now()); toast('Object shown again.'); }); actions.append(restore); meta.append(actions); row.append(preview, meta); return row; } function makeHiddenAssetCard(asset) { 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(); updateSelectionBubble(performance.now()); toast('Asset shown again.'); }); actions.append(restore); meta.append(actions); row.append(preview, meta); return 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' : (asset.subtype === 'ship' ? 'Ships' : '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: 0, facing: 1, idleUntil: performance.now() + 700 + Math.random() * 1600, hiddenUntil: 0, seed: Math.random() * 9999, nextDecisionAt: 0, nextBubbleAt: 800 + Math.random() * 1500, nextStepParticleAt: performance.now() + 110 + Math.random() * 160 }; }).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); landStepParticles = landStepParticles.filter((p) => time - p.started < p.life); natureDriftParticles = natureDriftParticles.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 (visualSettings().enableParticles && asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) { spawnFishBubbleCluster(item.x, item.y, time, item.seed); item.nextBubbleAt = time + 1100 + Math.random() * 1800; } if (time < (item.idleUntil || 0)) { item.vx = 0; continue; } const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); if (time < (item.idleUntil || 0)) { item.vx = 0; continue; } const dx = item.targetX - item.x; const dy = item.targetY - item.y; const len = Math.hypot(dx, dy) || 1; const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt; const step = Math.min(speed, len); if (Math.abs(dx) > 0.02) item.facing = Math.sign(dx); item.vx = Math.abs(dx) > 0.02 ? Math.sign(dx) : 0; item.x += (dx / len) * step; item.y += (dy / len) * step; if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) { const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1)); if (groundTile && groundTile.type !== 'water') { spawnGroundStepParticles(item, time, groundTile); } item.nextStepParticleAt = time + 110 + Math.random() * 140; } if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) { item.hiddenUntil = time + 1700 + Math.random() * 2200; } } } function chooseTarget(item, asset, time) { const idleChance = ({ human: .34, animal: .42, fish: .24, bird: .16 }[asset.subtype] || .32); if (Math.random() < idleChance) { item.idleUntil = time + 900 + Math.random() * 2600; item.targetX = item.x; item.targetY = item.y; item.vx = 0; item.nextDecisionAt = item.idleUntil + 400 + Math.random() * 1200; return; } 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.idleUntil = 0; item.nextDecisionAt = time + 1800 + Math.random() * 3200; } 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() { if (visualSettings().enableDayNight === false) { return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; } const t = mod(Date.now(), DAY_MS); const minute = t / 60000; const stops = [ // Fast dawn: sky shifts quickly, then stays stable through the day. { at: 0.00, key: 'morning', label: 'Morning', sky: '#f6b06f', darkness: 0.22, tint: [255, 204, 132, 0.16] }, { at: 0.55, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, { at: 5.00, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] }, // Fast dusk: the main sky movement happens here. { at: 5.55, key: 'evening', label: 'Evening', sky: '#f09076', darkness: 0.22, tint: [255, 128, 96, 0.15] }, { at: 6.10, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] }, { at: 10.00, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] } ]; 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; } } const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1); const eased = localT * localT * (3 - 2 * localT); const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased)); const label = minute < 0.55 ? 'Morning' : minute < 5 ? 'Day' : minute < 6.10 ? 'Evening' : 'Night'; return { key: label.toLowerCase(), label, progress: t / DAY_MS, sky: mixHex(a.sky, b.sky, eased), 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)})`, tintAlpha: tint[3], shadow: getShadowForMinute(minute) }; } function render(time = performance.now()) { const phase = getPhase(); renderPhase = phase; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cw, ch); ctx.fillStyle = phase.sky || '#86d5ff'; ctx.fillRect(0, 0, cw, ch); ctx.save(); ctx.translate(view.x, view.y); ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; drawTerrainCache(terrainCache); if (visualSettings().enableParticles) drawCoastalFoam(time); drawHoverTile(); const lightSources = []; const visibleItems = drawObjects(time, lightSources, phase); if (visualSettings().enableParticles) { spawnNatureAmbientParticles(visibleItems, time); drawNatureDriftParticles(time); drawLandStepParticles(time); drawBubbleParticles(time); drawSpawnEffects(time); drawConfettiParticles(time); } if (visualSettings().enableLights) drawSurfaceLightBleed(visibleItems, lightSources, time); ctx.restore(); updateSelectionBubble(time); if ((phase.tintAlpha || 0) > 0.001) { 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); } if (visualSettings().enableLights && phase.darkness > 0) 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); } } return items; } 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' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6; if (asset.category === 'dynamic') { const runtime = item.source; side = (runtime.facing || runtime.vx || 1) < 0 ? 'left' : 'right'; if (asset.subtype === 'human' || asset.subtype === 'animal') { const idle = time < (runtime.idleUntil || 0); if (!idle) { 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 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 }; } function drawSpriteItem(item, time, lightSources, underwater, phase) { const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true); if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id); if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase); 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(10, 16, asset.size / 64), ownerId: item.source?.id || asset.id }); } } } function drawSurfaceLightBleed(items, lightSources, time) { if (!items?.length || !lightSources?.length) return; for (const item of items) { const info = getSpriteDrawInfo(item, time, false); const sprite = info.sprite; const localSources = []; for (const source of lightSources) { if (source.ownerId === item.source?.id) continue; const dx = source.x - (info.drawX + sprite.width / 2); const dy = source.y - (info.drawY + sprite.height / 2); const reach = source.radius * 2.2 + Math.max(sprite.width, sprite.height) * 0.55; const distance = Math.hypot(dx, dy); if (distance > reach) continue; localSources.push({ source, distance }); } if (!localSources.length) continue; localSources.sort((a, b) => a.distance - b.distance); const overlay = document.createElement('canvas'); overlay.width = sprite.width; overlay.height = sprite.height; const octx = overlay.getContext('2d'); octx.imageSmoothingEnabled = false; octx.drawImage(sprite, 0, 0); octx.globalCompositeOperation = 'source-atop'; for (const { source, distance } of localSources.slice(0, 2)) { const localX = source.x - info.drawX; const localY = source.y - info.drawY; const radius = Math.max(12, source.radius * 1.7); const influence = 1 - clamp(distance / (source.radius * 2.4 + 36), 0, 1); if (influence <= 0.02) continue; const grad = octx.createRadialGradient(localX, localY, 0, localX, localY, radius); grad.addColorStop(0, hexToRgba(source.color, 0.22 * influence)); grad.addColorStop(0.5, hexToRgba(source.color, 0.10 * influence)); grad.addColorStop(1, 'rgba(255,255,255,0)'); octx.fillStyle = grad; octx.fillRect(0, 0, overlay.width, overlay.height); } ctx.save(); ctx.globalAlpha = 0.9; if (info.angle) { ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8)); ctx.rotate(info.angle); ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); } else { ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY)); } ctx.restore(); } } function spawnGroundStepParticles(item, time, tile) { const baseColor = getTerrainSurfaceColorAt(item.x, item.y); const count = 1 + (Math.random() < 0.5 ? 1 : 0); for (let i = 0; i < count; i++) { landStepParticles.push({ x: item.x + (Math.random() - 0.5) * 0.18, y: item.y + 0.16 + (Math.random() - 0.5) * 0.05, vx: (Math.random() - 0.5) * 0.045 - (item.vx || 0) * 0.012, vy: -0.015 - Math.random() * 0.02, started: time + i * 18, life: 260 + Math.random() * 180, color: varyHexColor(baseColor, 26, 16, 12), tileType: tile?.type || 'grass' }); } } function drawLandStepParticles(time) { if (!landStepParticles.length) return; ctx.save(); for (const particle of landStepParticles) { const age = (time - particle.started) / particle.life; if (age < 0 || age > 1) continue; const px = particle.x + particle.vx * age * 18; const py = particle.y + particle.vy * age * 18; const pos = tileToWorld(px, py); pos.y -= getLiftAtCoord(px, py); ctx.globalAlpha = (1 - age) * 0.7; ctx.fillStyle = particle.color; const size = age < 0.28 ? 2 : 1; ctx.fillRect(Math.round(pos.x), Math.round(pos.y + TILE_H * 0.22 - age * 3), size, size); } ctx.restore(); } function spawnNatureAmbientParticles(items, time) { if (natureDriftParticles.length > 120) return; const natureItems = items.filter((item) => item.asset.category === 'static' && item.asset.subtype === 'nature'); for (const item of natureItems) { if (Math.random() > 0.025) continue; const info = getSpriteDrawInfo(item, time, false); natureDriftParticles.push({ x: item.x + (Math.random() - 0.5) * 0.55, y: item.y - 0.15 - Math.random() * 0.7, vx: -0.012 + Math.random() * 0.024, vy: 0.018 + Math.random() * 0.024, started: time, life: 1800 + Math.random() * 1800, color: Math.random() < 0.55 ? ['#7abf64', '#99d57b', '#f4a1bb', '#ffd56e'][Math.floor(Math.random() * 4)] : ['#f7c3d7', '#ffef8b', '#8bd68f'][Math.floor(Math.random() * 3)], sway: Math.random() * Math.PI * 2, originDrawY: info.drawY }); } } function drawNatureDriftParticles(time) { if (!natureDriftParticles.length) return; ctx.save(); for (const particle of natureDriftParticles) { const age = (time - particle.started) / particle.life; if (age < 0 || age > 1) continue; const sway = Math.sin(age * 5.5 + particle.sway) * 0.08; const px = particle.x + particle.vx * age * 22 + sway; const py = particle.y + particle.vy * age * 22; const pos = tileToWorld(px, py); pos.y -= getLiftAtCoord(px, py); ctx.globalAlpha = (1 - age) * 0.72; ctx.fillStyle = particle.color; ctx.fillRect(Math.round(pos.x), Math.round(pos.y - age * 5), 1, 1); } ctx.restore(); } function drawShipRipples(pos, time, seedValue = '') { const seed = fnv1a(String(seedValue)).slice(0, 6); const numericSeed = parseInt(seed, 16) || 1; const cycleMs = 2300; const cycle = Math.floor((time + numericSeed) / cycleMs); if (pseudoNoise(cycle + numericSeed * 0.013) < 0.38) return; const t = ((time + numericSeed) % cycleMs) / cycleMs; const alpha = Math.max(0, 0.36 * (1 - t)); const rx = 5 + t * 22; const ry = 2 + t * 8; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = '#e8fbff'; ctx.lineWidth = Math.max(1, 1.5 / view.zoom); ctx.beginPath(); ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx, ry, 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } 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; if (els.bubbleReport) { const reported = hasReportForObject(objectId); els.bubbleReport.disabled = reported; els.bubbleReport.textContent = reported ? 'Reported' : 'Report'; } } 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 hasReportForObject(objectId) { const reporter = currentVoterKey(); return Array.isArray(state.moderationReports) && state.moderationReports.some((report) => report.objectId === objectId && report.reporter === reporter); } function openReportDialog() { if (!selectedObject) return; if (hasReportForObject(selectedObject.id)) { toast('Already reported locally.'); updateSelectionBubble(performance.now()); return; } const asset = findAsset(selectedObject.assetId); pendingReport = { ...selectedObject }; if (els.reportObjectName) els.reportObjectName.textContent = asset ? asset.name : 'selected object'; if (els.reportReason) els.reportReason.value = 'inappropriate'; if (els.reportDialog) { els.reportDialog.hidden = false; els.reportReason?.focus?.(); } else { submitReport('local-report'); } } function closeReportDialog() { pendingReport = null; if (els.reportDialog) els.reportDialog.hidden = true; } function submitReportDialog() { const reason = els.reportReason?.value || 'local-report'; submitReport(reason); } function submitReport(reason = 'local-report') { const target = pendingReport || selectedObject; if (!target) return; const asset = findAsset(target.assetId); state.moderationReports = normalizeModerationReports(state.moderationReports || []); state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; if (hasReportForObject(target.id)) { closeReportDialog(); toast('Already reported locally.'); updateSelectionBubble(performance.now()); return; } const report = { id: uid(), objectId: target.id, assetId: target.assetId, objectKind: target.kind, assetName: asset?.name || 'Untitled', assetAuthor: asset?.author || 'Local Artist', reporter: currentVoterKey(), reason, createdAt: Date.now() }; state.moderationReports.push(report); state.moderationReports = state.moderationReports.slice(-PHASE5_GUARDRAILS.maxReports); state.hiddenObjects ||= {}; state.hiddenObjects[target.id] = true; if (selectedObject?.id === target.id) selectedObject = null; closeReportDialog(); saveState(); hydrateRuntime(); updateSelectionBubble(performance.now()); renderLibrary(); toast('Reported and hidden locally.'); } 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(); for (const source of sources) { const sx = source.x * view.zoom + view.x; const sy = source.y * view.zoom + view.y; const radius = source.radius * view.zoom; const auraRadius = radius * 1.28; if (sx < -auraRadius || sy < -auraRadius || sx > cw + auraRadius || sy > ch + auraRadius) continue; // Light cores are drawn after the night overlay and do not inherit night darkness. ctx.globalCompositeOperation = 'source-over'; const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48)); core.addColorStop(0, hexToRgba(source.color, .84)); core.addColorStop(.34, hexToRgba(source.color, .44)); core.addColorStop(1, hexToRgba(source.color, .00)); ctx.fillStyle = core; ctx.beginPath(); ctx.arc(sx, sy, Math.max(3, radius * 0.48), 0, Math.PI * 2); ctx.fill(); ctx.globalCompositeOperation = 'lighter'; const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, auraRadius); glow.addColorStop(0, hexToRgba(source.color, .22)); glow.addColorStop(.42, hexToRgba(source.color, .10)); glow.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(sx, sy, auraRadius, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } function getSpriteCanvas(asset, side) { const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}` : 'day'; const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; if (spriteCache.has(key)) return spriteCache.get(key); const pixels = getAssetPixels(asset, side); const 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 = shadeAssetPixelColor(colorToHex(color), getAssetDepth(asset, x, y, side), x, y, asset.size, renderPhase); 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); } } const coastTiles = findCoastFoamTiles(worldData); if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize, coastTiles }; return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize, coastTiles }; } function findCoastFoamTiles(worldData) { const out = []; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (let y = 0; y < WORLD_H; y++) { for (let x = 0; x < WORLD_W; x++) { const tile = worldData.get(x, y); if (!tile || tile.type !== 'water') continue; let touchesLand = false; for (const [dx, dy] of dirs) { const near = worldData.get(x + dx, y + dy); if (near && near.type !== 'water') { touchesLand = true; break; } } if (touchesLand) out.push({ x, y }); } } return out; } 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 buildCoastalFoamTextures() { return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) }; } function makeFoamTexture(size, seedOffset) { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const c = canvas.getContext('2d', { alpha: true }); c.clearRect(0, 0, size, size); for (let i = 0; i < 26; i++) { const n = pseudoNoise(seedOffset * 93 + i * 17.3); const x = Math.floor(n * (size - 6)) + 3; const y = Math.floor(pseudoNoise(seedOffset * 77 + i * 11.1) * (size - 6)) + 3; const r = 1 + Math.floor(pseudoNoise(seedOffset * 41 + i * 6.2) * 3); c.fillStyle = i % 2 ? 'rgba(232, 248, 255, 0.42)' : 'rgba(216, 239, 248, 0.32)'; c.beginPath(); c.arc(x, y, r, 0, Math.PI * 2); c.fill(); c.globalCompositeOperation = 'destination-out'; c.beginPath(); c.arc(x + (i % 2 ? 0.4 : -0.6), y + (i % 3 ? 0.2 : -0.4), Math.max(0.6, r - 1.0), 0, Math.PI * 2); c.fill(); c.globalCompositeOperation = 'source-over'; } return canvas; } function drawCoastalFoam(time) { if (!terrainCache?.coastTiles?.length || !coastalFoamTextures) return; const rect = getViewportWorldRect(64); for (const tile of terrainCache.coastTiles) { const pos = tileToWorld(tile.x, tile.y); if (pos.x + 22 < rect.left || pos.x - 22 > rect.right || pos.y + 18 < rect.top || pos.y - 18 > rect.bottom) continue; const baseY = pos.y + TILE_H * 0.42; ctx.save(); ctx.globalAlpha = 0.34; ctx.translate(pos.x, baseY); ctx.rotate(time / 12000 + (tile.x + tile.y) * 0.03); ctx.drawImage(coastalFoamTextures.a, -12, -12); ctx.restore(); ctx.save(); ctx.globalAlpha = 0.22; ctx.translate(pos.x + 1, baseY + 1); ctx.rotate(-(time / 16500) + (tile.x - tile.y) * 0.025); ctx.drawImage(coastalFoamTextures.b, -12, -12); ctx.restore(); } } 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 getTerrainSurfaceColorAt(x, y) { const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1)); const palette = { water: '#8cd2ee', sand: '#ead99d', grass: '#91cf76', highland: '#9db781' }; return palette[tile?.type] || '#d9d2c0'; } 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.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) }; state.moderationReports = normalizeModerationReports(state.moderationReports || []); state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; 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(); updateGuardrailStats(); } 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 || {}, moderationReports: normalizeModerationReports(input.moderationReports || []), guardrails: { ...PHASE5_GUARDRAILS, ...(input.guardrails || {}) }, eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [], sync: input.sync || { lastEventId: null }, settings: { ...defaultVisualSettings(), ...(input.settings || {}) } }; } 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('Hill Cottage', 'static', 'building', drawCottage(), { hasLight: true, lightPixels: [{ x: 8, y: 8 }, { x: 9, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }), makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster()), makeAsset('Shell Rock', 'static', 'nature', drawShellRock()), makeAsset('Wave Skiff', 'static', 'ship', drawShip(), { depthPixels: drawShipDepth(), hasLight: false }), makeAsset('Fisher Kid', 'dynamic', 'human', drawFisherKidRight()), makeAsset('Moss Cat', 'dynamic', 'animal', drawCatRight()), makeAsset('Koi Fish', 'dynamic', 'fish', drawKoiRight()), makeAsset('Cloud Gull', 'dynamic', 'bird', drawGullRight()) ]; const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id])); const shipPos = findNearestTerrain('water', 62, 58); const fishPos = findNearestTerrain('water', 66, 60); return { schema: SAVE_SCHEMA, authorName: 'Local Artist', assets, placed: [ { id: uid(), assetId: idByName['Hill Cottage'], x: 36, y: 36, placedAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Pine Cluster'], x: 32, y: 36, placedAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Shell Rock'], x: 39, y: 40, placedAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Wave Skiff'], ...shipPos, placedAt: Date.now(), version: 1 } ], objectVotes: {}, assetVotes: {}, hiddenAssets: {}, hiddenObjects: {}, moderationReports: [], guardrails: { ...PHASE5_GUARDRAILS }, settings: defaultVisualSettings(), dynamicSummons: [ { id: uid(), assetId: idByName['Fisher Kid'], homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Moss Cat'], homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Koi Fish'], ...homeFromPos(fishPos), createdAt: Date.now(), version: 1 }, { id: uid(), assetId: idByName['Cloud Gull'], homeX: 90, homeY: 30, createdAt: Date.now(), version: 1 } ] }; } 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 getWorldObjectCount() { return (state.placed?.length || 0) + (state.dynamicSummons?.length || 0); } function normalizeModerationReports(input) { const list = Array.isArray(input) ? input : []; return list .map((report) => ({ id: report.id || uid(), objectId: report.objectId || report.id || '', assetId: report.assetId || '', objectKind: report.objectKind === 'dynamic' ? 'dynamic' : 'static', assetName: report.assetName || 'Untitled', assetAuthor: report.assetAuthor || 'Local Artist', reporter: report.reporter || 'local', reason: report.reason || 'local-report', createdAt: Number(report.createdAt) || Date.now() })) .filter((report) => report.objectId && report.assetId) .slice(-PHASE5_GUARDRAILS.maxReports); } function collectWorldValidation() { const assetIds = new Set((state.assets || []).map((asset) => asset.id)); const contentCounts = new Map(); for (const asset of state.assets || []) { const key = asset.contentHash || computeAssetContentHash(asset); contentCounts.set(key, (contentCounts.get(key) || 0) + 1); } const orphanStatic = (state.placed || []).filter((item) => !assetIds.has(item.assetId)); const orphanDynamic = (state.dynamicSummons || []).filter((item) => !assetIds.has(item.assetId)); const invalidTerrain = []; for (const item of state.placed || []) { const asset = findAsset(item.assetId); const tile = world.get(item.x, item.y); if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'static', id: item.id, assetId: item.assetId, x: item.x, y: item.y }); } for (const item of state.dynamicSummons || []) { const asset = findAsset(item.assetId); const tile = world.get(Math.round(item.homeX), Math.round(item.homeY)); if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'dynamic', id: item.id, assetId: item.assetId, x: item.homeX, y: item.homeY }); } const duplicateContent = [...contentCounts.values()].filter((count) => count > 1).reduce((sum, count) => sum + count - 1, 0); return { schema: SAVE_SCHEMA, generatedAt: new Date().toISOString(), assets: state.assets?.length || 0, worldObjects: getWorldObjectCount(), hiddenObjects: Object.keys(state.hiddenObjects || {}).length, reports: state.moderationReports?.length || 0, duplicateContent, orphanStatic: orphanStatic.length, orphanDynamic: orphanDynamic.length, invalidTerrain: invalidTerrain.length, invalidTerrainObjects: invalidTerrain, limits: state.guardrails || PHASE5_GUARDRAILS }; } function isTerrainCompatible(asset, tile) { if (!asset || !tile) return false; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water'; if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water'; return tile.type !== 'water'; } function updateGuardrailStats() { if (!els.guardrailStats) return; const report = collectWorldValidation(); const assetText = `${report.assets}/${report.limits.maxAssets}`; const objectText = `${report.worldObjects}/${report.limits.maxWorldObjects}`; const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain; els.guardrailStats.textContent = `Assets ${assetText}, objects ${objectText}, reports ${report.reports}, hidden ${report.hiddenObjects}, duplicate-content copies ${report.duplicateContent}, validation warnings ${warnings}.`; } function validateWorld() { const report = collectWorldValidation(); els.dataBox.value = JSON.stringify({ format: 'pixel-island-phase5-validation-v1', ...report }, null, 2); updateGuardrailStats(); const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain; toast(warnings ? `Validation found ${warnings} warning(s).` : 'Validation passed.'); } function exportModerationReport() { const validation = collectWorldValidation(); const payload = { format: 'pixel-island-phase5-moderation-v1', generatedAt: validation.generatedAt, authorName: state.authorName || 'Local Artist', reports: normalizeModerationReports(state.moderationReports || []), hiddenObjects: state.hiddenObjects || {}, downvotedObjects: Object.entries(state.objectVotes || {}) .map(([objectId, votes]) => ({ objectId, up: votes.up || 0, down: votes.down || 0, score: (votes.up || 0) - (votes.down || 0) })) .filter((item) => item.down > 0), validation }; els.dataBox.value = JSON.stringify(payload, null, 2); toast('Moderation report exported.'); } function clearModerationReports() { if (!state.moderationReports?.length) { toast('No local reports to clear.'); return; } if (!confirm('Clear local moderation reports? Hidden objects stay hidden.')) return; state.moderationReports = []; saveState(); updateGuardrailStats(); toast('Local reports cleared.'); } 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++) { const ch = input[i]; out[i] = ch === '1' ? 1 : (ch === '-' || ch === '_' || ch === 'l' || ch === 'L') ? -1 : 0; } return out; } if (!Array.isArray(input)) return out; for (let i = 0; i < Math.min(out.length, input.length); i++) { const value = Number(input[i]) || 0; out[i] = value > 0 ? 1 : value < 0 ? -1 : 0; } return out; } function encodeDepthPixels(input) { return Array.from(input || []).map((v) => Number(v) > 0 ? '1' : Number(v) < 0 ? '-' : '.').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) { return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase); } function shadeAssetPixelColor(hex, depth, x, y, size, phase) { const rgb = parseHex(hex); if (!rgb) return hex; const activePhase = phase || renderPhase || getPhase(); const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1); const vertical = 1 - (y / Math.max(1, size - 1)); const horizontal = ((x / Math.max(1, size - 1)) - 0.5) * dirX; const sunlight = (activePhase?.key === 'night' ? 8 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 12 : 16); const depthBoost = depth * sunlight; const exposure = (vertical * 0.7 + horizontal * 0.5) * (activePhase?.key === 'night' ? 9 : 13); const amt = depthBoost + exposure; const r = clamp(rgb.r + amt, 0, 255); const g = clamp(rgb.g + amt, 0, 255); const b = clamp(rgb.b + amt, 0, 255); return `#${Math.round(r).toString(16).padStart(2,'0')}${Math.round(g).toString(16).padStart(2,'0')}${Math.round(b).toString(16).padStart(2,'0')}`; } function 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 hexToRgbParts(hex) { const clean = String(hex || '#000000').replace('#', ''); const expanded = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean.padEnd(6, '0').slice(0, 6); const bigint = parseInt(expanded, 16); return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 }; } function rgbToHsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s; const l = (max + min) / 2; if (max === min) { h = 0; s = 0; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; break; } h *= 60; } return { h, s: s * 100, l: l * 100 }; } function varyHexColor(hex, hueRange = 12, satRange = 10, lightRange = 10) { const rgb = parseHex(hex); if (!rgb) return hex; const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); const h = mod(hsl.h + (Math.random() * 2 - 1) * hueRange, 360); const s = clamp(hsl.s + (Math.random() * 2 - 1) * satRange, 14, 100); const l = clamp(hsl.l + (Math.random() * 2 - 1) * lightRange, 10, 94); return hslToHex(h, s, l); } function mixHex(a, b, t) { const ca = hexToRgbParts(a); const cb = hexToRgbParts(b); const toHex = (value) => Math.round(value).toString(16).padStart(2, '0'); return `#${toHex(lerp(ca.r, cb.r, t))}${toHex(lerp(ca.g, cb.g, t))}${toHex(lerp(ca.b, cb.b, t))}`; } 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 drawCottage() { const s = 16, p = blankPixels(s); rect(p, s, 4, 8, 8, 6, '#b86b4f'); rect(p, s, 5, 9, 6, 4, '#d68b5e'); rect(p, s, 6, 11, 3, 3, '#3b3348'); rect(p, s, 10, 9, 2, 2, '#ffe38a'); rect(p, s, 3, 7, 10, 1, '#6a485d'); for (let y = 3; y <= 7; y++) { const inset = Math.abs(6 - y); for (let x = 3 + inset; x <= 12 - inset; x++) px(p, s, x, y, y < 5 ? '#8e4f61' : '#74465b'); } px(p, s, 6, 8, '#ffcf75'); px(p, s, 11, 12, '#7b493f'); return p; } function drawPineCluster() { const s = 16, p = blankPixels(s); rect(p, s, 6, 9, 2, 5, '#765238'); rect(p, s, 10, 10, 2, 4, '#765238'); for (let y = 2; y <= 10; y++) { const w = Math.floor((y + 1) / 2); for (let x = 7 - w; x <= 7 + w; x++) px(p, s, x, y, y % 2 ? '#348f5a' : '#42aa68'); } for (let y = 5; y <= 11; y++) { const w = Math.floor((y - 2) / 2); for (let x = 11 - w; x <= 11 + w; x++) px(p, s, x, y, y % 2 ? '#2f7f55' : '#3d9d62'); } px(p, s, 5, 4, '#8ee68c'); px(p, s, 10, 6, '#8ee68c'); return p; } function drawShellRock() { const s = 16, p = blankPixels(s); rect(p, s, 5, 10, 7, 3, '#b9ad9f'); rect(p, s, 4, 11, 9, 2, '#968b83'); px(p, s, 6, 9, '#f0e0ca'); px(p, s, 8, 9, '#f0e0ca'); px(p, s, 10, 9, '#f0e0ca'); rect(p, s, 7, 8, 4, 2, '#ffb6b1'); px(p, s, 9, 7, '#ffd7cb'); px(p, s, 11, 10, '#6d625f'); return p; } function drawShip() { const s = 16, p = blankPixels(s); rect(p, s, 4, 10, 8, 2, '#7d513c'); rect(p, s, 5, 12, 6, 1, '#50392f'); px(p, s, 3, 9, '#7d513c'); px(p, s, 12, 9, '#7d513c'); rect(p, s, 8, 4, 1, 6, '#5b4b48'); for (let y = 4; y <= 8; y++) for (let x = 9; x <= 12 - Math.floor((y - 4) / 2); x++) px(p, s, x, y, '#f3ead8'); for (let y = 5; y <= 8; y++) for (let x = 5 + Math.floor((y - 5) / 2); x <= 7; x++) px(p, s, x, y, '#ffd66e'); px(p, s, 10, 5, '#ffffff'); px(p, s, 6, 6, '#fff7d1'); return p; } function drawShipDepth() { const s = 16, d = Array(s * s).fill(0); for (let y = 10; y <= 12; y++) for (let x = 4; x <= 12; x++) d[y * s + x] = -1; for (let y = 4; y <= 8; y++) d[y * s + 8] = 1; return d; } function drawFisherKidRight() { const s = 16, p = blankPixels(s); rect(p, s, 7, 3, 3, 3, '#d9a06c'); rect(p, s, 6, 6, 5, 4, '#e2b94f'); rect(p, s, 6, 10, 2, 4, '#31577e'); rect(p, s, 9, 10, 2, 4, '#31577e'); rect(p, s, 6, 2, 5, 1, '#35434a'); px(p, s, 10, 4, '#202631'); px(p, s, 12, 7, '#5b4b48'); px(p, s, 13, 8, '#5b4b48'); return p; } function drawCatRight() { const s = 16, p = blankPixels(s); rect(p, s, 4, 8, 7, 4, '#5c8a57'); rect(p, s, 10, 7, 3, 3, '#6fa064'); px(p, s, 10, 6, '#6fa064'); px(p, s, 12, 6, '#6fa064'); px(p, s, 12, 8, '#1e2430'); px(p, s, 13, 9, '#d9a06c'); rect(p, s, 5, 12, 1, 2, '#3e633e'); rect(p, s, 9, 12, 1, 2, '#3e633e'); px(p, s, 3, 8, '#5c8a57'); px(p, s, 2, 7, '#5c8a57'); return p; } function drawKoiRight() { const s = 16, p = blankPixels(s); rect(p, s, 5, 7, 6, 3, '#f7f2e8'); px(p, s, 6, 7, '#ff7d5a'); px(p, s, 8, 8, '#ff7d5a'); px(p, s, 10, 9, '#ff7d5a'); px(p, s, 11, 8, '#1d2530'); px(p, s, 4, 7, '#ffb165'); px(p, s, 3, 6, '#ffb165'); px(p, s, 3, 10, '#ffb165'); px(p, s, 7, 6, '#fffdf7'); px(p, s, 9, 10, '#fffdf7'); return p; } function drawGullRight() { const s = 16, p = blankPixels(s); rect(p, s, 7, 6, 3, 3, '#fffdf7'); rect(p, s, 4, 6, 3, 1, '#dfe6ee'); rect(p, s, 10, 5, 4, 1, '#dfe6ee'); px(p, s, 10, 7, '#f0a45c'); px(p, s, 8, 5, '#1e2430'); px(p, s, 6, 8, '#c9d2dc'); px(p, s, 9, 8, '#c9d2dc'); return p; } bootstrap(); })();