(() => { 'use strict'; console.info('Pixel Island Summoner loaded'); const STORAGE_KEY = 'pixel-island-summoner:phase6b'; const LEGACY_STORAGE_KEYS = ['pixel-island-summoner:phase6a']; const SAVE_SCHEMA = 27; 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 TARGET_RENDER_FRAME_MS = 1000 / 30; const MAX_DYNAMIC_STEPS_PER_FRAME = 3; const MAX_SPRITE_CACHE_ENTRIES = 260; const DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER = 5; const BASE_COLOR_CODES = '0123456789abcdefghij'; const ADVANCED_COLOR_CODES = 'klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~"; const COLOR_CODES = BASE_COLOR_CODES + ADVANCED_COLOR_CODES; const BASIC_PALETTE_COUNT = BASE_COLOR_CODES.length; const DEFAULT_SELECTED_COLOR_CODE = BASE_COLOR_CODES.includes('a') ? 'a' : (BASE_COLOR_CODES[0] || '0'); const PALETTE = buildPalette(); const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color])); const MODULES = window.PixelIslandModules || {}; const Phase2Sync = MODULES.Phase2Sync || null; const StateIndex = MODULES.StateIndex || null; const RotationPolicy = MODULES.RotationPolicy || null; const Lighting = MODULES.Lighting || null; const ModuleLoader = MODULES.ModuleLoader || null; const EDITOR_HISTORY_LIMIT = 80; const MAX_EDITOR_DIMENSION = 64; const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 }; const REMOVED_LOW_QUALITY_SEED_ASSET_NAMES = new Set([ 'Shell Rock', 'Azure Minnow', 'Firefly Swirl', 'Leaf Sparrow', 'Silver Trout', 'Butterfly Fish' ]); const editorActions = () => MODULES.EditorActions || null; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5); const mod = (n, m) => ((n % m) + m) % m; const lerp = (a, b, t) => a + (b - a) * t; function clampDimension(value, fallback = 8) { return clampInt ? clampInt(value, 1, MAX_EDITOR_DIMENSION, fallback) : Math.max(1, Math.min(MAX_EDITOR_DIMENSION, Math.round(Number(value) || fallback))); } function rectArea(width, height = width) { return Math.max(1, Math.floor(Number(width) || 1)) * Math.max(1, Math.floor(Number(height) || width || 1)); } function lightBudgetForArea(width, height = width) { return Math.max(1, Math.ceil(rectArea(width, height) / 100)); } function assetWidth(asset) { return clampInt(asset?.width ?? asset?.w ?? asset?.size, 1, MAX_EDITOR_DIMENSION, 16); } function assetHeight(asset) { return clampInt(asset?.height ?? asset?.ht ?? asset?.size, 1, MAX_EDITOR_DIMENSION, assetWidth(asset)); } function assetMaxSize(asset) { return Math.max(assetWidth(asset), assetHeight(asset)); } function editorCellSize() { return Math.max(1, Math.floor(Math.min(els.paintCanvas.width / Math.max(1, editorWidth), els.paintCanvas.height / Math.max(1, editorHeight)))); } function updateDimensionInputs() { if (els.assetWidth) els.assetWidth.value = String(editorWidth); if (els.assetHeight) els.assetHeight.value = String(editorHeight); if (els.assetSize) { const preset = editorWidth === editorHeight && [8, 16, 32, 64].includes(editorWidth) ? editorWidth : ''; els.assetSize.value = preset ? String(preset) : String(Math.max(8, Math.min(64, editorSize))); } } function defaultVisualSettings() { return { enableLights: true, enableParticles: true, enableDayNight: true, localDisplayLimit: PHASE5_GUARDRAILS.defaultDisplayLimit }; } function visualSettings() { state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) }; return state.settings; } const DEFAULT_SERVER_AUTHORITY = Object.freeze({ publish: 'server', objectMove: 'server', dayNight: 'local', dynamicMotion: 'server' }); function serverAuthority() { ensureWorldProtectionState(); state.serverSync.authority = { ...DEFAULT_SERVER_AUTHORITY, ...(state.serverSync.authority || {}) }; return state.serverSync.authority; } function isServerAuthoritative(feature) { if (feature === 'dayNight') return false; return serverAuthority()[feature] === 'server'; } function getAuthoritativeNow() { return Date.now(); } function getPendingSharedVisuals() { ensureWorldProtectionState(); const visuals = state.serverSync.pendingObjectVisuals || {}; const now = Date.now(); const out = []; let dirty = false; for (const [id, entry] of Object.entries(visuals)) { if (!entry || !entry.object || !entry.assetId) { delete visuals[id]; dirty = true; continue; } if (Number(entry.expiresAt || 0) > 0 && now > Number(entry.expiresAt)) { delete visuals[id]; dirty = true; continue; } out.push(entry); } if (dirty) state.serverSync.pendingObjectVisuals = visuals; return out; } 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; if (els.displayLimit) els.displayLimit.value = String(getLocalDisplayLimit()); } 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' || role === 'bird' || role === 'fish' ? 'dynamic' : 'static'; } function roleToSubtype(role) { return role || 'other'; } function subtypeToRole(asset) { const subtype = asset?.subtype; if (['human', 'animal', 'bird', 'fish', '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'), drawQuotaBadge: $('drawQuotaBadge'), finishQuotaBadge: $('finishQuotaBadge'), openCreate: $('openCreate'), openCollection: $('openCollection'), openMenu: $('openMenu'), closeEditor: $('closeEditor'), placementPreviewBar: $('placementPreviewBar'), confirmPreviewPlace: $('confirmPreviewPlace'), backToCanvas: $('backToCanvas'), createAccount: $('createAccount'), accountNote: $('accountNote'), accountId: $('accountId'), accountPass: $('accountPass'), drawer: $('studioDrawer'), tabs: [...document.querySelectorAll('.tab')], panels: [...document.querySelectorAll('.tabPanel')], phaseLabel: $('phaseLabel'), phaseBar: $('phaseBar'), analogClock: $('analogClock'), analogClockHand: $('analogClockHand'), authorName: $('authorName'), selectedAssetName: $('selectedAssetName'), tileInfo: $('tileInfo'), toast: $('toast'), selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleTeleport: $('bubbleTeleport'), bubbleMenu: $('bubbleMenu'), bubbleMenuActions: $('bubbleMenuActions'), bubbleEdit: $('bubbleEdit'), 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'), assetWidth: $('assetWidth'), assetHeight: $('assetHeight'), 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'), toolParticle: $('toolParticle'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), advancedToolGroup: $('advancedToolGroup'), particleDirectionWrap: $('particleDirectionWrap'), particleDirection: $('particleDirection'), particleUseSelection: $('particleUseSelection'), particleClearRange: $('particleClearRange'), particleRangeStatus: $('particleRangeStatus'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'), lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'), settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'), lineageNote: $('lineageNote'), assetList: $('assetList'), likedCodex: $('likedCodex'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'), settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), displayLimit: $('displayLimit'), rotationStats: $('rotationStats'), reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName'), confirmDialog: $('confirmDialog'), confirmTitle: $('confirmTitle'), confirmMessage: $('confirmMessage'), confirmOk: $('confirmOk'), confirmCancel: $('confirmCancel') }; 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 cursorScreen = { x: 0, y: 0, active: false }; let hoverTile = null; let dynamicRuntime = []; let spriteCache = new Map(); let lastRenderedSpriteInfo = new Map(); let lastRuntimeUpdate = performance.now(); let lastRenderAt = 0; let lastClockSecond = -1; let toastTimer = null; let editorWidth = 8; let editorHeight = 8; let editorSize = 8; let editorPixels = blankPixels(8); let editorLeftPixels = blankPixels(8); let editingSide = 'right'; let paintTool = 'brush'; let selectedColorCode = DEFAULT_SELECTED_COLOR_CODE; let advancedDraw = false; let activeTabName = 'draw'; let depthPixels = blankPixels(8).map(() => 0); let depthPaintMode = 1; let isPainting = false; let staticKind = 'nature'; let dynamicKind = 'human'; let lightPixels = []; let particlePixels = []; let particleConfig = { enabled: false, c: 'f', dir: 'up' }; let doorPixel = { x: 8, y: 15 }; let editParentId = null; let editOriginalId = null; 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 shipReflectionCanvasCache = new WeakMap(); let shadowMaskCanvas = null; let shadowMaskCtx = null; let activeShadowCtx = null; let animationFrameId = 0; let dynamicLogicRemainder = 0; let worldIndex = makeEmptyWorldIndex(); let selectedObject = null; let cameraFollowSelected = false; let pendingReport = null; let libraryFilter = 'all'; let librarySearch = ''; let libraryViewTab = 'mine'; let defaultGalleryAssetNames = null; 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; let placementPreview = null; function bootstrap() { resizeCanvas(); resetView(false); rebuildWorldIndex(); hydrateRuntime(); coastalFoamTextures = buildCoastalFoamTextures(); wireUI(); renderPalette(); hydrateAuthorUI(); updateAccountUI(); refreshCategoryUI(); setupEditor(8, blankPixels(8), null); clearEditorHistory(); renderLibrary(); updateSelectedLabel(); hydrateVisualSettingsUI(); updateRotationStats(); scheduleFrame(); } function wireUI() { window.addEventListener('resize', () => { resizeCanvas(); render(); }); els.openEditor?.addEventListener('click', () => { clearWorldSelection(false); setDrawerOpen(true); }); els.openCreate?.addEventListener('click', () => { setTab('draw'); clearWorldSelection(false); setDrawerOpen(true); }); els.openCollection?.addEventListener('click', () => { setTab('library', { preserveScroll: true }); clearWorldSelection(false); setDrawerOpen(true); }); els.closeEditor.addEventListener('click', () => setDrawerOpen(false)); els.tabs.forEach((button) => { button.addEventListener('click', () => setTab(button.dataset.tab, { preserveScroll: button.dataset.tab === 'library' })); }); els.authorName?.addEventListener('input', () => { const fallback = state.account?.id || 'Local Artist'; state.authorName = (els.authorName.value || fallback).trim() || fallback; if (state.account) state.account.name = state.authorName; saveState(); updateAccountUI(); renderLibrary(); }); els.accountPass?.addEventListener('input', () => { if (!state.account) return; state.account.password = (els.accountPass.value || '').trim(); saveState(); updateAccountUI(); }); els.createAccount?.addEventListener('click', () => createLocalAccount(false)); els.confirmPreviewPlace?.addEventListener('click', confirmPreviewPlacement); els.backToCanvas?.addEventListener('click', cancelPlacementPreview); els.settingLights?.addEventListener('change', () => { visualSettings().enableLights = !!els.settingLights.checked; spriteCache.clear(); saveState(); render(); }); 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.displayLimit?.addEventListener('change', () => { const value = clampInt(els.displayLimit.value, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); visualSettings().localDisplayLimit = value; els.displayLimit.value = String(value); saveState(); renderLibrary(); 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(); cancelPlacementPreview(false); clearWorldSelection(true); setMode('inspect'); }); document.addEventListener('contextmenu', onDocumentContextMenu); document.addEventListener('visibilitychange', onVisibilityChange); els.assetSize?.addEventListener('change', () => { const nextSize = clampDimension(els.assetSize.value, editorSize); resizeEditorCanvas(nextSize, nextSize); }); [els.assetWidth, els.assetHeight].filter(Boolean).forEach((input) => { input.addEventListener('change', () => { resizeEditorCanvas(clampDimension(els.assetWidth?.value, editorWidth), clampDimension(els.assetHeight?.value, editorHeight)); }); }); 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.toolParticle?.addEventListener('click', () => { setPaintTool('particle'); enableParticleEffect(); }); els.particleDirection?.addEventListener('change', () => { const before = JSON.stringify(particleConfig); particleConfig = normalizeParticleConfig({ ...particleConfig, dir: els.particleDirection.value || 'up' }); if (particleConfig.enabled && JSON.stringify(particleConfig) !== before) markEditorChanged(); updateParticleUI(); renderPalette(); drawEditor(); }); els.particleUseSelection?.addEventListener('click', setParticleRangeFromSelection); els.particleClearRange?.addEventListener('click', clearParticleRange); els.depthHigh?.addEventListener('click', () => setDepthPaintMode(1)); els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1)); els.toggleAdvanced?.addEventListener('click', () => { setAdvancedDraw(!advancedDraw); }); els.clearPaint.addEventListener('click', () => { if (!confirm('Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.')) return; commitEditorMutation(() => { editorPixels = blankPixels(editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = blankPixels(editorSize).map(() => 0); lightPixels = []; particlePixels = []; particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; editorSelection = null; return true; }); }); els.flipHorizontal?.addEventListener('click', flipEditorHorizontal); els.flipVertical?.addEventListener('click', flipEditorVertical); els.outlinePaint?.addEventListener('click', applyEditorOutline); els.clearSelection?.addEventListener('click', () => clearEditorSelection(true)); els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0)); els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0)); els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1)); els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1)); 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.bubbleEdit?.addEventListener('click', () => editSelectedOriginal()); els.bubbleTeleport?.addEventListener('click', () => teleportToRemixSource()); els.bubbleMenu?.addEventListener('click', () => toggleBubbleMenu()); els.bubbleReport?.addEventListener('click', () => openReportDialog()); els.reportCancel?.addEventListener('click', () => closeReportDialog()); els.reportSubmit?.addEventListener('click', () => submitReportDialog()); els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); }); els.confirmCancel?.addEventListener('click', () => resolveConfirmDialog(false)); els.confirmOk?.addEventListener('click', () => resolveConfirmDialog(true)); els.confirmDialog?.addEventListener('click', (event) => { if (event.target === els.confirmDialog) resolveConfirmDialog(false); }); 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.checkOnIsland?.addEventListener('click', checkCurrentEditorOnIsland); els.newAsset?.addEventListener('click', newAsset); 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); if (!cursorScreen.active) cursorScreen = { x: cw / 2, y: ch / 2, active: false }; } 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) ensureEditorHelpers(); if (!open) { selectedObject = null; cameraFollowSelected = false; updateSelectionBubble(performance.now()); } } function ensureEditorHelpers() { if (editorActions() || !ModuleLoader?.loadScript) return; ModuleLoader.loadScript('./js/editor-actions.js') .catch((error) => console.warn('Editor helper lazy load failed; using inline fallbacks.', error)); } function onDocumentContextMenu(event) { if (!els.drawer?.classList.contains('open')) return; if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || els.openCreate?.contains(event.target) || els.openCollection?.contains(event.target) || els.openMenu?.contains(event.target)) return; event.preventDefault(); setDrawerOpen(false); } function setTab(name, options = {}) { const scroller = els.drawer?.querySelector('.drawerBody'); const keepScroll = Boolean(options.preserveScroll); const scrollTop = keepScroll ? scroller?.scrollTop : null; activeTabName = 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}`)); if (name !== 'draw' && advancedDraw) setAdvancedDraw(false, { redraw: false }); else els.drawer?.classList.toggle('advancedTools', name === 'draw' && advancedDraw); if (keepScroll && Number.isFinite(scrollTop)) restoreDrawerScroll(scrollTop); } function setAdvancedDraw(enabled, options = {}) { const redraw = options.redraw !== false; advancedDraw = Boolean(enabled); const selectedPaletteIndex = PALETTE.findIndex((entry) => entry.code === selectedColorCode); if (!advancedDraw && selectedPaletteIndex >= BASIC_PALETTE_COUNT) { selectedColorCode = nearestBasicPaletteCode(PALETTE_BY_CODE[selectedColorCode] || PALETTE_BY_CODE[DEFAULT_SELECTED_COLOR_CODE] || '#ffffff'); } toggleHidden(els.advancedToolGroup, !advancedDraw); toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); toggleHidden(els.advancedHint, !advancedDraw); els.toggleAdvanced?.classList.toggle('active', advancedDraw); if (els.toggleAdvanced) els.toggleAdvanced.textContent = advancedDraw ? '竏・Advanced' : '・・Advanced'; els.drawer?.classList.toggle('advancedTools', activeTabName === 'draw' && advancedDraw); if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush'); updateParticleUI(); renderPalette(); if (redraw) drawEditor(); } function setMode(nextMode) { mode = nextMode; if (mode === 'place' || mode === 'erase') clearWorldSelection(false); 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() { els.drawer?.classList.toggle('advancedTools', activeTabName === 'draw' && advancedDraw); 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.advancedToolGroup, !advancedDraw); toggleHidden(els.toolLight, !advancedDraw); toggleHidden(els.toolParticle, !advancedDraw); toggleHidden(els.toolDepth, !advancedDraw); toggleHidden(els.depthHigh, !advancedDraw); toggleHidden(els.depthLow, !advancedDraw); toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); toggleHidden(els.toolDoor, role !== 'building'); toggleHidden(els.doorMarkerHint, role !== 'building'); if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth' || paintTool === 'particle')) setPaintTool('brush'); if (!isStatic && paintTool === 'door') setPaintTool('brush'); if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush'); if (isStatic) editingSide = 'right'; updateRoleHint(); updateSideButtons(); clampEditorView(); drawEditor(); updateParticleUI(); updateSettingsSummary(); } function updateRoleHint() { if (!els.roleHint) return; const role = currentRole(); const textMap = { human: 'Humans move by target direction. Draw as 笆カ Right, or press 笳€ Left if your canvas is left-facing so Save mirrors it.', animal: 'Animals move by target direction. Draw as 笆カ Right, or press 笳€ Left if your canvas is left-facing so Save mirrors it.', bird: 'Birds fly over terrain and seek nature. Draw as 笆カ Right, or press 笳€ Left if your canvas is left-facing so Save mirrors it.', nature: 'Nature attracts animals and birds. Static sprites are drawn at 2テ・scale.', fish: 'Fish swim on water tiles. Draw as right-facing, or use Left if your canvas is left-facing so Save mirrors it.', 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 maxLights = lightBudgetForArea(editorWidth, editorHeight); const lightText = lightCount ? `${lightCount}/${maxLights} lamp cell${lightCount === 1 ? '' : 's'}` : `no light (${maxLights} max)`; const particleText = particlePixels.length ? `particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : 'no particles'; const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : ''; els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}テ・{editorHeight} canvas / 2テ・static pixels / ${lightText} / ${particleText}${doorText}.`; } else { const particleText = particlePixels.length ? ` / particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : ''; const sideText = editingSide === 'left' ? 'canvas marked 笳€ Left; Save mirrors it into canonical 笆カ Right' : 'canvas marked canonical 笆カ Right'; els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}テ・{editorHeight} canvas / ${sideText}${particleText}.`; } } function setPaintTool(tool) { paintTool = tool; [els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolParticle, 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, particle: els.toolParticle, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active'); const hints = { brush: 'Draw pixels with the selected palette color.', erase: 'Erase metadata first when Light/Depth/Particle exists on the cell; erase the pixel on the next click.', fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.', pick: 'Pick a color from the canvas and return to Draw.', line: 'Drag to draw a straight line. Right-click erases along the line.', rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.', select: 'Left-click/drag to select pixels. Right-click clears the selection.', light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.', particle: 'Particle emits from the sprite or selected range. Palette chooses color; direction is set below. Shift-click disables particles; right-click pans.', door: 'Click one pixel to mark a building door. Humans will enter near this point.', depth: 'Advanced: paint high or low depth. Use High/Low buttons; Shift/right-click clears.' }; els.editHint.textContent = hints[tool] || hints.brush; [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); toggleHidden(els.particleDirectionWrap, !advancedDraw || tool !== 'particle'); updateParticleUI(); } function setDepthPaintMode(mode) { depthPaintMode = mode === -1 ? -1 : 1; [els.depthHigh, els.depthLow].filter(Boolean).forEach((button) => button.classList.remove('active')); ({ 1: els.depthHigh, '-1': els.depthLow }[depthPaintMode])?.classList.add('active'); if (paintTool !== 'depth') setPaintTool('depth'); const label = depthPaintMode > 0 ? 'high' : 'low'; if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Shift/right-click clears depth.`; } function setEditingSide(side) { if (roleToCategory(currentRole()) !== 'dynamic') return; editingSide = side === 'left' ? 'left' : 'right'; updateSideButtons(); if (els.editHint) { els.editHint.textContent = editingSide === 'left' ? 'Canvas is marked as left-facing. It will be mirrored into the canonical right-facing sprite on save.' : 'Canvas is marked as the canonical right-facing sprite. Movement uses this for rightward travel.'; } drawEditor(); } 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(); const downPos = getCanvasPoint(event); cursorScreen = { x: downPos.x, y: downPos.y, active: true }; if (event.button === 2) { cancelPlacementPreview(false); clearWorldSelection(true); setMode('inspect'); 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 }; } function onWorldPointerMove(event) { const pos = getCanvasPoint(event); cursorScreen = { x: pos.x, y: pos.y, active: true }; 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 > 8) { pointer.dragging = true; els.canvas.classList.add('dragging'); } if (pointer.dragging) { cameraFollowSelected = false; 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); cursorScreen = { x: pos.x, y: pos.y, active: true }; const tile = screenToTile(pos.x, pos.y); if (wasClick) { const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now()); 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(); cameraFollowSelected = false; 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 worldToTileFloat(worldX, worldY) { const a = (worldX - ORIGIN_X) / (TILE_W / 2); const b = (worldY - ORIGIN_Y) / (TILE_H / 2); return { x: (a + b) / 2, y: (b - a) / 2 }; } function tileKey(x, y) { return `${x},${y}`; } function makeEmptyWorldIndex() { return StateIndex?.build ? StateIndex.build({ assets: [], placed: [], dynamicSummons: [] }) : { assetById: new Map(), staticById: new Map(), dynamicById: new Map(), objectsById: new Map(), objectIdsByAssetId: new Map(), placedByTile: new Map(), dynamicByHomeTile: new Map() }; } function pushIndexBucket(map, key, item) { const bucket = map.get(key); if (bucket) bucket.push(item); else map.set(key, [item]); } function rebuildWorldIndex() { if (StateIndex?.build) { worldIndex = StateIndex.build(state); return; } const next = makeEmptyWorldIndex(); for (const asset of state.assets || []) { if (asset?.id) next.assetById.set(asset.id, asset); } for (const placed of state.placed || []) { next.staticById.set(placed.id, placed); next.objectsById.set(placed.id, { kind: 'static', object: placed }); pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed); pushIndexBucket(next.objectIdsByAssetId, placed.assetId, placed.id); } for (const summon of state.dynamicSummons || []) { next.dynamicById.set(summon.id, summon); next.objectsById.set(summon.id, { kind: 'dynamic', object: summon }); pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon); pushIndexBucket(next.objectIdsByAssetId, summon.assetId, summon.id); } worldIndex = next; } function getPlacedAtTile(x, y) { return worldIndex.placedByTile.get(tileKey(x, y)) || []; } function getDynamicHomesAtTile(x, y) { return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || []; } function getLocalDisplayLimit() { const settings = visualSettings(); return clampInt(settings.localDisplayLimit, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit); } function getObjectRecord(kind, objectId) { if (kind === 'static') return worldIndex.staticById?.get(objectId) || state.placed.find((p) => p.id === objectId) || null; return worldIndex.dynamicById?.get(objectId) || state.dynamicSummons.find((p) => p.id === objectId) || null; } function getObjectPublicAt(kind, object) { return RotationPolicy?.objectPublicAt ? RotationPolicy.objectPublicAt(object) : Number(object?.publishedAt || object?.placedAt || object?.createdAt || Date.now()); } function isPermanentlyHiddenObject(object) { return object?.status === 'permanent_hidden' || object?.status === 'violation_hidden' || object?.permanentHidden === true; } function isModeratedAssetHidden(asset) { if (!asset) return false; if (asset.status === 'permanent_hidden' || asset.status === 'violation_hidden') return true; const objectIds = worldIndex.objectIdsByAssetId?.get(asset.id) || []; for (const objectId of objectIds) { const entry = worldIndex.objectsById?.get(objectId); if (entry?.object && isPermanentlyHiddenObject(entry.object) && entry.object.hiddenReason === 'moderation_violation') return true; } return false; } function isServerSuppressedObject(object) { return ['archived', 'permanent_hidden', 'violation_hidden'].includes(object?.status); } function getObjectRotationEntry(kind, object, baseIndex = 0) { const votes = getObjectVoteCounts(object?.id); if (RotationPolicy?.entry) return RotationPolicy.entry(kind, object, baseIndex, votes, PHASE5_GUARDRAILS); const rawUp = Number(votes.up) || 0; const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap); const down = Number(votes.down) || 0; return { kind, object, id: object?.id, assetId: object?.assetId, publicAt: getObjectPublicAt(kind, object), baseIndex, up, rawUp, down, effectiveSlot: baseIndex + up * PHASE5_GUARDRAILS.upvoteDelaySlots - down * PHASE5_GUARDRAILS.downvoteAdvanceSlots }; } function getRotationEntries(includeLocalHidden = false) { const entries = []; for (const placed of state.placed || []) { const asset = findAsset(placed.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || isServerSuppressedObject(placed)) continue; if (!includeLocalHidden && state.hiddenObjects?.[placed.id]) continue; entries.push({ kind: 'static', object: placed }); } for (const summon of state.dynamicSummons || []) { const asset = findAsset(summon.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || isServerSuppressedObject(summon)) continue; if (!includeLocalHidden && state.hiddenObjects?.[summon.id]) continue; entries.push({ kind: 'dynamic', object: summon }); } entries.sort((a, b) => getObjectPublicAt(a.kind, a.object) - getObjectPublicAt(b.kind, b.object) || String(a.object.id).localeCompare(String(b.object.id))); return entries.map((entry, index) => getObjectRotationEntry(entry.kind, entry.object, index)); } function seededScore(id, salt = '') { if (RotationPolicy?.seededScore) return RotationPolicy.seededScore(id, salt, state.lastRotationAt || Date.now()); const hash = fnv1a(`${id}|${salt}|${Math.floor((state.lastRotationAt || Date.now()) / (24 * 60 * 60 * 1000))}`); return parseInt(hash.slice(0, 8), 16) / 0xffffffff; } function getIslandDisplayBuckets() { const entries = getRotationEntries(false); const localLimit = getLocalDisplayLimit(); if (RotationPolicy?.buckets) return RotationPolicy.buckets(entries, localLimit, PHASE5_GUARDRAILS, state.lastRotationAt || Date.now()); const newCap = Math.min(PHASE5_GUARDRAILS.newArrivalSlots, localLimit); const revivalCap = Math.max(0, Math.min(PHASE5_GUARDRAILS.revivalSlots, localLimit - newCap)); const newest = entries .slice() .sort((a, b) => b.effectiveSlot - a.effectiveSlot || b.publicAt - a.publicAt || String(b.id).localeCompare(String(a.id))) .slice(0, newCap); const newestIds = new Set(newest.map((entry) => entry.id)); const revivalPool = entries.filter((entry) => !newestIds.has(entry.id)); const revival = revivalPool .slice() .sort((a, b) => seededScore(b.id, 'revival') - seededScore(a.id, 'revival') || b.up - a.up || String(b.id).localeCompare(String(a.id))) .slice(0, revivalCap); return { newest, revival, entries, visibleIds: new Set([...newest, ...revival].map((entry) => entry.id)) }; } function getVisibleRotationIdSet() { return getIslandDisplayBuckets().visibleIds; } function isWorldObjectVisibleByRotation(kind, objectId) { const object = getObjectRecord(kind, objectId); if (!object || isServerSuppressedObject(object) || state.hiddenObjects?.[objectId]) return false; return getVisibleRotationIdSet().has(objectId); } function updateRotationStats() { if (!els.rotationStats) return; const buckets = getIslandDisplayBuckets(); const entries = buckets.entries; const visible = buckets.visibleIds.size; const hiddenByRotation = Math.max(0, entries.length - visible); const limit = getLocalDisplayLimit(); const publish = getPublishQuotaStatus(); const quotaText = publish.accountRequired ? 'account required to publish' : `publish quota ${publish.used}/${publish.limit} this hour`; els.rotationStats.textContent = `Island exhibition ${visible}/${entries.length} objects ツキ newest ${buckets.newest.length}/150 ツキ revival ${buckets.revival.length}/100 ツキ local cap ${limit} ツキ rotated out ${hiddenByRotation} ツキ ${quotaText}.`; updateAccountUI(); } function getAccountAgeMs(now = Date.now()) { return state.account?.createdAt ? now - Number(state.account.createdAt) : 0; } function currentPublishLimit(now = Date.now()) { if (!state.account?.createdAt) return 0; if (RotationPolicy?.publishLimit) return RotationPolicy.publishLimit(state.account, now, PHASE5_GUARDRAILS); return getAccountAgeMs(now) < 24 * 60 * 60 * 1000 ? PHASE5_GUARDRAILS.publishLimitFirstDay : PHASE5_GUARDRAILS.publishLimitTrusted; } function getPublishQuotaStatus(now = Date.now()) { state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; const author = currentVoterKey(); const windowMs = 60 * 60 * 1000; state.publishLog = state.publishLog.filter((entry) => now - Number(entry.at || 0) < windowMs * 24); const used = state.publishLog.filter((entry) => entry.author === author && now - Number(entry.at || 0) < windowMs).length; const limit = currentPublishLimit(now); return { used, limit, remaining: Math.max(0, limit - used), accountRequired: !state.account?.createdAt }; } function canPublishObject(action = 'publish') { if (!state.account?.createdAt) ensureLocalAccount(action === 'publish' ? 'publish' : 'republish'); const quota = getPublishQuotaStatus(); if (quota.accountRequired) { toast('Create a local account before publishing works to the island.'); updateAccountUI(); return false; } if (quota.remaining <= 0) { toast(`Publish limit reached: ${quota.limit} public placements per hour.`); updatePublishQuotaUI(); return false; } return true; } function recordObjectPublish(kind, object, action = 'publish') { if (!object) return; const now = Date.now(); object.publishedAt = now; object.status = 'active'; state.publishLog = Array.isArray(state.publishLog) ? state.publishLog : []; state.publishLog.push({ at: now, author: currentVoterKey(), kind, objectId: object.id, assetId: object.assetId, action }); state.publishLog = state.publishLog.slice(-300); updatePublishQuotaUI(); } function isSharedWorld() { return state.worldMode === 'shared'; } function currentAccountId() { return String(state.account?.id || '').trim(); } function normalizeOwnerAccountId(value, fallback = '') { return String(value || fallback || '').trim(); } function ensureWorldProtectionState(target = state) { target.worldMode = target.worldMode === 'shared' ? 'shared' : 'local'; target.serverSync = { lastServerEventId: target.serverSync?.lastServerEventId || null, pendingCommands: Array.isArray(target.serverSync?.pendingCommands) ? target.serverSync.pendingCommands.slice(-300) : [], authority: { ...DEFAULT_SERVER_AUTHORITY, ...(target.serverSync?.authority || {}) }, clock: target.serverSync?.clock && typeof target.serverSync.clock === 'object' ? { ...target.serverSync.clock } : { worldTimeMs: Date.now(), syncedAt: Date.now() }, dynamicTargets: target.serverSync?.dynamicTargets && typeof target.serverSync.dynamicTargets === 'object' ? { ...target.serverSync.dynamicTargets } : {}, pendingObjectVisuals: target.serverSync?.pendingObjectVisuals && typeof target.serverSync.pendingObjectVisuals === 'object' ? { ...target.serverSync.pendingObjectVisuals } : {} }; target.tombstones = { assets: target.tombstones?.assets && typeof target.tombstones.assets === 'object' ? target.tombstones.assets : {}, objects: target.tombstones?.objects && typeof target.tombstones.objects === 'object' ? target.tombstones.objects : {} }; target.deletedSeedAssetNames = Array.isArray(target.deletedSeedAssetNames) ? [...new Set(target.deletedSeedAssetNames.filter(Boolean).map(String))] : []; return target; } function isAssetTombstoned(assetId, version = 0) { const tombstone = state.tombstones?.assets?.[assetId]; return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0)); } function isObjectTombstoned(objectId, version = 0) { const tombstone = state.tombstones?.objects?.[objectId]; return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0)); } function getObjectOwner(kind, object) { if (!object) return ''; return normalizeOwnerAccountId(object.ownerAccountId, findAsset(object.assetId)?.ownerAccountId || ''); } function canCurrentAccountModifyObject(kind, object, showToast = true) { if (!isSharedWorld()) { const allowed = isCurrentUserAsset(findAsset(object?.assetId)); if (!allowed && showToast) toast('Only the owner can move or delete this island object.'); return allowed; } const actor = currentAccountId(); if (!actor) { if (showToast) toast('Shared worlds require an account before changing island objects.'); return false; } const owner = getObjectOwner(kind, object); if (owner && owner === actor) return true; if (showToast) toast('Only the owner can move or delete this island object.'); return false; } function removeObjectLocally(kind, objectId) { if (!objectId) return null; const list = kind === 'dynamic' ? state.dynamicSummons : state.placed; const index = list.findIndex((item) => item.id === objectId); if (index < 0) return null; const [object] = list.splice(index, 1); ensureWorldProtectionState(); state.tombstones.objects[objectId] = { id: objectId, assetId: object.assetId, deletedAt: Date.now(), deletedBy: currentAccountId() || 'local', ownerAccountId: normalizeOwnerAccountId(object.ownerAccountId), version: Number(object.version) || 1 }; delete state.objectVotes?.[objectId]; delete state.hiddenObjects?.[objectId]; state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== objectId); if (selectedObject?.id === objectId) selectedObject = null; rebuildWorldIndex(); if (kind === 'dynamic') hydrateRuntime(); saveState(); updateSelectionBubble(performance.now()); return object; } function canCurrentAccountDeleteAsset(asset, showToast = true) { if (!isCurrentUserAsset(asset)) { if (showToast) toast('Only the owner can delete this asset.'); return false; } if (!isSharedWorld()) return true; const actor = currentAccountId(); if (!actor) { if (showToast) toast('Shared worlds require an account before deleting assets.'); return false; } const owner = normalizeOwnerAccountId(asset?.ownerAccountId); if (owner && owner === actor) return true; if (showToast) toast('Only the owner can delete this asset in a shared world.'); return false; } function isCurrentUserAsset(asset) { if (!asset) return false; if (isSampleGalleryAsset(asset)) return false; if (!isSharedWorld()) { const owner = normalizeOwnerAccountId(asset.ownerAccountId); if (owner) return owner === currentAccountId(); return (asset.author || 'Local Artist') === (state.authorName || 'Local Artist'); } return normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId(); } function displayAssetAuthor(asset) { if (!asset) return state.authorName || 'Local Artist'; return isSharedWorld() ? (asset.author || 'Local Artist') : (state.authorName || 'Local Artist'); } function makeSharedCommand(type, payload = {}) { return { id: `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, type, actorAccountId: currentAccountId(), createdAt: Date.now(), ...payload }; } function queueSharedCommand(command, options = {}) { if (!command) return; ensureWorldProtectionState(); state.serverSync.pendingCommands.push(command); state.serverSync.pendingCommands = state.serverSync.pendingCommands.slice(-300); if (command.object?.id && command.object?.assetId) { const visualKind = command.kind === 'dynamic' ? 'dynamic' : 'static'; const existing = visualKind === 'dynamic' ? state.dynamicSummons.find((item) => item.id === command.object.id) : state.placed.find((item) => item.id === command.object.id); const toX = visualKind === 'dynamic' ? Number(command.object.homeX) : Number(command.object.x); const toY = visualKind === 'dynamic' ? Number(command.object.homeY) : Number(command.object.y); state.serverSync.pendingObjectVisuals[command.object.id] = { kind: visualKind, assetId: command.object.assetId, object: { ...command.object }, fromX: Number(existing ? (visualKind === 'dynamic' ? existing.homeX : existing.x) : toX), fromY: Number(existing ? (visualKind === 'dynamic' ? existing.homeY : existing.y) : toY), toX, toY, createdAt: command.createdAt || Date.now(), expiresAt: Date.now() + 15000, commandId: command.id }; } saveState(); if (options.toast !== false) toast('Change queued for server validation.'); } function shouldHideUnderPlacementPreview(assetId, x, y) { return false; } function getDrawableItems(time = performance.now(), viewport = null) { const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN); const items = []; const pendingVisuals = getPendingSharedVisuals(); const pendingVisualIds = new Set(pendingVisuals.map((entry) => String(entry.object?.id || ''))); for (const placed of state.placed) { if (pendingVisualIds.has(String(placed.id || ''))) continue; const asset = findAsset(placed.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id] || isPermanentlyHiddenObject(placed) || !isWorldObjectVisibleByRotation('static', placed.id)) continue; if (shouldHideUnderPlacementPreview(asset.id, placed.x + .5, placed.y + .5)) continue; const itemX = placed.x + .5; const itemY = placed.y + .5; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed }); } for (const runtime of dynamicRuntime) { if (pendingVisualIds.has(String(runtime.id || ''))) continue; const asset = findAsset(runtime.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id] || isPermanentlyHiddenObject(runtime) || !isWorldObjectVisibleByRotation('dynamic', runtime.id)) continue; if (shouldHideUnderPlacementPreview(asset.id, runtime.x, runtime.y)) continue; if (time < runtime.hiddenUntil) continue; if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue; items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime }); } for (const pending of pendingVisuals) { const asset = findAsset(pending.assetId); if (!asset || isModeratedAssetHidden(asset) || state.hiddenAssets?.[asset.id]) continue; const source = pending.object || {}; const targetX = Number.isFinite(Number(pending.toX)) ? Number(pending.toX) : (pending.kind === 'dynamic' ? Number(source.homeX) : Number(source.x)); const targetY = Number.isFinite(Number(pending.toY)) ? Number(pending.toY) : (pending.kind === 'dynamic' ? Number(source.homeY) : Number(source.y)); const startX = Number.isFinite(Number(pending.fromX)) ? Number(pending.fromX) : targetX; const startY = Number.isFinite(Number(pending.fromY)) ? Number(pending.fromY) : targetY; const t = clamp((Date.now() - Number(pending.createdAt || Date.now())) / 520, 0, 1); const eased = 1 - Math.pow(1 - t, 3); const itemX = lerp(startX, targetX, eased) + .5; const itemY = lerp(startY, targetY, eased) + .5; if (!Number.isFinite(itemX) || !Number.isFinite(itemY)) continue; if (!isApproxVisible(asset, itemX, itemY, rect)) continue; items.push({ kind: pending.kind, asset, x: itemX, y: itemY, source: { ...source, pending: true }, pending: true }); } if (placementPreview?.asset && placementPreview.x != null && placementPreview.y != null) { const asset = placementPreview.asset; items.push({ kind: asset.category === 'dynamic' ? 'dynamic' : 'static', asset, x: placementPreview.x + .5, y: placementPreview.y + .5, source: { id: 'placement-preview', preview: true }, preview: true }); } items.sort(drawOrderCompare); return items; } function getObjectSortBottom(item) { const pos = tileToWorld(item.x, item.y); const asset = item.asset || {}; if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(item.x, item.y); const anchorY = asset.category === 'static' ? 0 : TILE_H / 2; return pos.y + anchorY; } function drawOrderCompare(a, b) { const bottomDelta = getObjectSortBottom(a) - getObjectSortBottom(b); if (Math.abs(bottomDelta) > 0.01) return bottomDelta; return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || '')); } 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 = assetWidth(asset) * scale + 96; const h = assetHeight(asset) * scale + 128; return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom; } 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; // Small works at 16テ・6 or below are hard to click. Use the whole sprite box // for them, including transparent cells, while larger works still use opaque pixels. const aw = assetWidth(asset); const ah = assetHeight(asset); if (Math.max(aw, ah) <= 16) return true; const px = Math.floor(localX / scale); const py = Math.floor(localY / scale); if (px < 0 || py < 0 || px >= aw || py >= ah) return false; const pixels = getAssetPixels(asset, info.side || 'right'); return Boolean(pixels[py * aw + 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).filter((p) => isWorldObjectVisibleByRotation('static', p.id)).length; const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).filter((p) => isWorldObjectVisibleByRotation('dynamic', p.id)).length; els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`; } function clearWorldSelection(announce = false) { const hadSelection = !!selectedObject; selectedObject = null; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; updateSelectionBubble(performance.now()); if (announce && hadSelection) toast('Selection cleared.'); } function updateSelectedLabel() { if (selectedAssetId && !findAsset(selectedAssetId)) { selectedAssetId = state.assets.find((asset) => !state.hiddenAssets?.[asset.id])?.id || state.assets[0]?.id || null; } const asset = findAsset(selectedAssetId); if (els.selectedAssetName) { els.selectedAssetName.textContent = asset ? `${asset.name || 'Untitled'} ツキ ${cap(subtypeToRole(asset))}` : 'No collection work selected'; } } function inspectAt(x, y) { const tile = world.get(x, y); const staticObjects = getPlacedAtTile(x, y).filter((p) => isWorldObjectVisibleByRotation('static', p.id)); const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => isWorldObjectVisibleByRotation('dynamic', 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 }; cameraFollowSelected = true; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; selectedAssetId = assetId; updateSelectedLabel(); renderLibrary(); render(selectedAt); updateSelectionBubble(selectedAt); } function isTileCompatibleForAsset(asset, tile) { if (!tile) return false; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water'; if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water'; if (tile.type === 'water') return asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship'); return true; } function getRemixSourcePositions(asset) { const ids = new Set([asset?.parentAssetId, asset?.originalAssetId].filter(Boolean)); if (!ids.size) return []; const out = []; for (const placed of state.placed || []) if (ids.has(placed.assetId)) out.push({ x: placed.x, y: placed.y }); for (const summon of state.dynamicSummons || []) if (ids.has(summon.assetId)) out.push({ x: summon.homeX, y: summon.homeY }); return out; } function avoidRemixSourceOverlap(asset, x, y) { return { x, y, moved: false }; } function placeSelected(x, y) { if (placementPreview?.asset) { const adjusted = avoidRemixSourceOverlap(placementPreview.asset, x, y); x = adjusted.x; y = adjusted.y; const tile = world.get(x, y); if (!canPlace(placementPreview.asset, tile)) return; placementPreview.x = x; placementPreview.y = y; if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now(), preview: true }); render(); toast(adjusted.moved ? 'Preview nudged away from the remix source. Use Place here or Back to canvas.' : 'Preview set. Use Place here or Back to canvas.'); return; } const asset = findAsset(selectedAssetId); if (!asset) { toast('Select an asset first.'); setDrawerOpen(true); setTab('library'); return; } const adjusted = avoidRemixSourceOverlap(asset, x, y); x = adjusted.x; y = adjusted.y; let tile = world.get(x, y); if (!canPlace(asset, tile)) return; if (adjusted.moved) toast('Placed slightly away from the remix source to avoid overlap.'); const existingObject = asset.category === 'static' ? state.placed.find((p) => p.assetId === asset.id) : state.dynamicSummons.find((p) => p.assetId === asset.id); const kind = asset.category === 'dynamic' ? 'dynamic' : 'static'; const wasLocallyHidden = existingObject ? Boolean(state.hiddenObjects?.[existingObject.id]) : false; const wasRotationHidden = existingObject ? !isWorldObjectVisibleByRotation(kind, existingObject.id) : false; const needsPublishSlot = !existingObject || wasLocallyHidden || wasRotationHidden; if (needsPublishSlot && !canPublishObject(existingObject ? 'republish' : 'publish')) return; const isNewObject = !existingObject; if (isNewObject && getWorldObjectCount() >= PHASE5_GUARDRAILS.maxWorldObjects) { toast(`Local storage object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Lower the display cap or remove local objects before adding more.`); return; } if (isSharedWorld()) { ensureLocalAccount(existingObject ? 'republish' : 'publish'); const actor = currentAccountId(); if (!normalizeOwnerAccountId(asset.ownerAccountId)) asset.ownerAccountId = actor; if (normalizeOwnerAccountId(asset.ownerAccountId) !== actor) { toast('Shared worlds only let you publish placements from assets you own. Use Remix to make your own version.'); return; } if (existingObject && !canCurrentAccountModifyObject(kind, existingObject)) return; const nextObject = asset.category === 'static' ? { ...(existingObject || {}), id: existingObject?.id || uid(), assetId: asset.id, ownerAccountId: actor, x, y, placedAt: Date.now(), publishedAt: existingObject?.publishedAt || Date.now(), status: 'active', version: Number(existingObject?.version || 0) + 1 } : { ...(existingObject || {}), id: existingObject?.id || uid(), assetId: asset.id, ownerAccountId: actor, homeX: x, homeY: y, createdAt: Date.now(), publishedAt: existingObject?.publishedAt || Date.now(), status: 'active', version: Number(existingObject?.version || 0) + 1 }; queueSharedCommand(makeSharedCommand(existingObject ? 'object.move' : 'object.publish', { kind, object: nextObject })); return; } let object = null; if (asset.category === 'static') { const existing = state.placed.find((p) => p.assetId === asset.id); if (existing) { existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1; if (needsPublishSlot) { delete state.hiddenObjects?.[existing.id]; recordObjectPublish('static', existing, wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish'); } object = existing; selectWorldObject('static', existing.id, asset.id, performance.now()); toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { const placed = { id: uid(), assetId: asset.id, ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()), x, y, placedAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.placed.push(placed); recordObjectPublish('static', placed, 'publish'); object = placed; 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; if (needsPublishSlot) { delete state.hiddenObjects?.[existing.id]; recordObjectPublish('dynamic', existing, wasLocallyHidden || wasRotationHidden ? 'republish' : 'publish'); } object = existing; toast(needsPublishSlot ? `${asset.name} republished.` : `${asset.name} moved.`); } else { const summon = { id: uid(), assetId: asset.id, ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()), homeX: x, homeY: y, createdAt: Date.now(), publishedAt: Date.now(), status: 'active', version: 1 }; state.dynamicSummons.push(summon); recordObjectPublish('dynamic', summon, 'publish'); object = summon; 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()); } rebuildWorldIndex(); recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(kind, object)); if (visualSettings().enableParticles) spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() }); saveState(); updateRotationStats(); } 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) { if (!canCurrentAccountModifyObject('static', staticTarget)) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId: staticTarget.id })); return; } const item = removeObjectLocally('static', staticTarget.id); if (item) { if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Object'} removed.`); return; } } const dynamicObjects = getDynamicHomesAtTile(x, y); const dynamicTarget = dynamicObjects.at(-1); if (dynamicTarget) { if (!canCurrentAccountModifyObject('dynamic', dynamicTarget)) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId: dynamicTarget.id })); return; } const item = removeObjectLocally('dynamic', dynamicTarget.id); if (item) { if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id)); saveState(); } toast(`${findAsset(item.assetId)?.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) { if (!canCurrentAccountModifyObject('static', state.placed[index])) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId })); return; } const item = removeObjectLocally('static', objectId); if (!item) return; if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Object'} removed.`); return; } } if (kind === 'dynamic') { const index = state.dynamicSummons.findIndex((p) => p.id === objectId); if (index >= 0) { if (!canCurrentAccountModifyObject('dynamic', state.dynamicSummons[index])) return; if (isSharedWorld()) { queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId })); return; } const item = removeObjectLocally('dynamic', objectId); if (!item) return; if (!isSharedWorld()) { recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId)); saveState(); } toast(`${findAsset(item.assetId)?.name ?? 'Dynamic object'} removed.`); } } } function usesRightButtonAsPaint() { return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase'; } function onPaintPointerDown(event) { event.preventDefault(); if (paintTool === 'select' && event.button === 2) { clearEditorSelection(true); return; } els.paintCanvas.setPointerCapture?.(event.pointerId); lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); 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(); if (paintTool === 'select' && event.button === 2) { clearEditorSelection(true); return; } lastPaintedKey = ''; const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint()); mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button }; 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 >= editorWidth || y >= editorHeight) 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 = compactPixelsFromStride(editorPixels, editorSize, editorWidth, editorHeight); const fillColor = shiftKey ? null : selectedColorCode; const actions = editorActions(); const nextDisplay = actions?.floodFill ? actions.floodFill(displayPixels, editorWidth, x, y, fillColor, editorHeight) : fallbackFloodFill(displayPixels, editorWidth, x, y, fillColor, editorHeight); if (nextDisplay.changed) { setDisplayEditorPixels(nextDisplay.pixels); 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); removeParticlePixel(src.x, src.y); } } changed = true; } } else if (paintTool === 'brush') { if (editorPixels[index] !== selectedColorCode) { editorPixels[index] = selectedColorCode; changed = true; } } else if (paintTool === 'erase') { const hadLight = lightPixels.some((p) => p.x === point.x && p.y === point.y); const hadParticle = particlePixels.some((p) => p.x === point.x && p.y === point.y); const hadDepth = depthPixels[index] !== 0; if (hadLight || hadParticle || hadDepth) { removeLightPixel(point.x, point.y); removeParticlePixel(point.x, point.y); depthPixels[index] = 0; changed = true; } else if (editorPixels[index] !== null) { editorPixels[index] = null; changed = true; } } 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 === 'particle') { const before = JSON.stringify(particlePixels); if (shiftKey || button === 2) removeParticlePixel(point.x, point.y); else addParticlePixel(point.x, point.y, selectedColorCode, els.particleDirection?.value || particleConfig.dir || 'up'); particleConfig = normalizeParticleConfig({ enabled: particlePixels.length > 0, c: selectedColorCode, dir: els.particleDirection?.value || particleConfig.dir || 'up' }); changed = JSON.stringify(particlePixels) !== before; updateParticleUI(); updateSettingsSummary(); } else if (paintTool === 'depth') { const next = (shiftKey || button === 2) ? 0 : 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 = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); 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 = editorCellSize(); return { x: (sx - editorView.x) / editorView.zoom / cell, y: (sy - editorView.y) / editorView.zoom / cell }; } function snapshotEditorState() { return { size: editorSize, width: editorWidth, height: editorHeight, rightPixels: [...editorPixels], depthPixels: [...depthPixels], lightPixels: lightPixels.map((p) => ({ ...p })), particlePixels: particlePixels.map((p) => ({ ...p })), particleConfig: { ...particleConfig }, doorPixel: { ...doorPixel }, editingSide, selectedColorCode, editorSelection: editorSelection ? { ...editorSelection } : null }; } function restoreEditorState(snapshot) { editorWidth = clampDimension(snapshot.width ?? snapshot.size, editorWidth); editorHeight = clampDimension(snapshot.height ?? snapshot.size, editorHeight); editorSize = Math.max(editorWidth, editorHeight); editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize); lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : []; particlePixels = Array.isArray(snapshot.particlePixels) ? snapshot.particlePixels.map((p) => ({ ...p })) : []; particleConfig = normalizeParticleConfig(snapshot.particleConfig || (particlePixels.length ? { enabled: true, c: particlePixels[0].c, dir: particlePixels[0].dir || 'up' } : particleConfig)); doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorWidth / 2), y: editorHeight - 1 }; editingSide = snapshot.editingSide || editingSide; if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode; editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null; updateDimensionInputs(); 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 = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); 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') { clearEditorSelection(false); } else if (!event.ctrlKey && !event.metaKey && !event.altKey) { const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' }; if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); } } } function fallbackFloodFill(sourcePixels, width, x, y, colorCode, height = width) { const w = Math.max(1, Math.round(Number(width) || 1)); const h = Math.max(1, Math.round(Number(height) || w)); const pixels = [...sourcePixels]; const target = pixels[y * w + x] || null; const replacement = colorCode || null; if (target === replacement) return { pixels, changed: false, count: 0, cells: [] }; const stack = [[x, y]]; const cells = []; while (stack.length) { const [cx, cy] = stack.pop(); if (cx < 0 || cy < 0 || cx >= w || cy >= h) continue; const index = cy * w + 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 })), baseParticlePixels: particlePixels.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.baseParticlePixels, 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 = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); 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 >= editorWidth || y >= editorHeight) return null; return { x, y }; } function normalizeSelectionRect(x0, y0, x1, y1) { const left = clamp(Math.min(x0, x1), 0, editorWidth - 1); const top = clamp(Math.min(y0, y1), 0, editorHeight - 1); const right = clamp(Math.max(x0, x1), 0, editorWidth - 1); const bottom = clamp(Math.max(y0, y1), 0, editorHeight - 1); return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 }; } 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, editorWidth - (selection.x + selection.w)), dy: clamp(dy, -selection.y, editorHeight - (selection.y + selection.h)) }; } function getDisplayDepthPixels() { return normalizeDepthPixels(depthPixels, editorSize); } function setDisplayEditorPixels(pixels) { editorPixels = Array.isArray(pixels) && pixels.length === editorWidth * editorHeight && (editorWidth !== editorSize || editorHeight !== editorSize) ? inflatePixelsToStride(pixels, editorWidth, editorHeight, editorSize) : normalizePixels(pixels, editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); } function setDisplayDepthPixels(pixels) { depthPixels = Array.isArray(pixels) && pixels.length === editorWidth * editorHeight && (editorWidth !== editorSize || editorHeight !== editorSize) ? inflateDepthToStride(pixels, editorWidth, editorHeight, editorSize) : normalizeDepthPixels(pixels, editorSize); } function mirrorScalarPixels(values, stride, width = stride, height = width) { const source = normalizeDepthPixels(values, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { out[y * stride + (width - 1 - x)] = source[y * stride + x] || 0; } } return out; } function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, baseParticlePixels, 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 = moveDisplayPoints(baseLightPixels, selection, dx, dy); particlePixels = moveDisplayPoints(baseParticlePixels, selection, dx, dy); editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h }; updateEditorSelectionButtons(); } function moveDisplayPoints(sourcePoints, selection, dx, dy) { const out = []; for (const point of sourcePoints || []) { const shown = canonicalCellToDisplay(point.x, point.y); if (!pointInSelection(shown.x, shown.y, selection)) { out.push({ ...point }); continue; } const nx = shown.x + dx; const ny = shown.y + dy; if (nx < 0 || ny < 0 || nx >= editorWidth || ny >= editorHeight) continue; const canonical = displayCellToCanonical(nx, ny); out.push({ ...point, 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 })), particlePixels.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: editorWidth, h: editorHeight }; const bounded = clampSelectionDelta(selection, dx, dy); if (!bounded.dx && !bounded.dy) return false; applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), particlePixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy); editorSelection = null; doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorWidth - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorHeight - 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 < editorWidth && cell.y < editorHeight); } 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 clearEditorSelection(announce = false) { const hadSelection = !!editorSelection || !!selectionGesture; editorSelection = null; selectionGesture = null; drawEditor(); updateEditorSelectionButtons(); if (announce && hadSelection) toast('Selection cleared.'); } function normalizeParticleConfig(config, size = editorSize) { const input = config && typeof config === 'object' ? config : {}; const dir = ['up', 'down', 'left', 'right'].includes(input.dir) ? input.dir : 'up'; const color = PALETTE_BY_CODE[input.c] ? input.c : nearestPaletteCode('#ffffff'); return { enabled: Boolean(input.enabled), c: color, dir }; } function particleDirectionLabel(dir) { return ({ up: '↑', down: '↓', left: '←', right: '→' }[dir] || '↑'); } function particleRangeLabel() { return `${particlePixels.length} emitter cell${particlePixels.length === 1 ? '' : 's'}`; } function updateParticleUI() { particleConfig = normalizeParticleConfig(particleConfig); if (els.particleDirection) els.particleDirection.value = particleConfig.dir; toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle'); if (els.toolParticle) els.toolParticle.classList.toggle('active', paintTool === 'particle' || particlePixels.length > 0); if (els.particleRangeStatus) els.particleRangeStatus.textContent = `Particle cells: ${particlePixels.length}. Shift/right-click clears a cell.`; if (els.particleClearRange) els.particleClearRange.disabled = particlePixels.length === 0; } function enableParticleEffect() { particleConfig = normalizeParticleConfig({ enabled: true, c: selectedColorCode, dir: els.particleDirection?.value || particleConfig.dir || 'up' }); updateParticleUI(); updateSettingsSummary(); drawEditor(); } function disableParticleEffect() { if (!particlePixels.length && !particleConfig.enabled) return; particleConfig = normalizeParticleConfig({ ...particleConfig, enabled: false }); updateParticleUI(); updateSettingsSummary(); drawEditor(); toast('Particle tool ready. Shift/right-click a particle cell to clear it.'); } function displayRectToCanonicalRect(rect) { return null; } function setParticleRangeFromSelection() { toast('Particle range is no longer used. Paint particle cells directly.'); } function clearParticleRange() { if (!particlePixels.length) return; commitEditorMutation(() => { particlePixels = []; particleConfig = normalizeParticleConfig({ ...particleConfig, enabled: false }); return true; }); toast('Particle cells cleared.'); } function getPixelBounds(pixels, size) { const source = normalizePixels(pixels, size); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (!source[y * size + x]) continue; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } return Number.isFinite(minX) ? { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 } : null; } function flipEditorHorizontal() { commitEditorMutation(() => { editorPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = mirrorScalarPixels(depthPixels, editorSize, editorWidth, editorHeight); lightPixels = lightPixels.map((p) => ({ ...p, x: editorWidth - 1 - p.x })); particlePixels = particlePixels.map((p) => ({ ...p, x: editorWidth - 1 - p.x })); doorPixel = { ...doorPixel, x: editorWidth - 1 - doorPixel.x }; editorSelection = editorSelection ? { x: editorWidth - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null; return true; }); } function flipEditorVertical() { commitEditorMutation(() => { editorPixels = flipPixelsVertical(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = flipScalarPixelsVertical(depthPixels, editorSize, editorWidth, editorHeight); lightPixels = lightPixels.map((p) => ({ ...p, y: editorHeight - 1 - p.y })); particlePixels = particlePixels.map((p) => ({ ...p, y: editorHeight - 1 - p.y })); doorPixel = { ...doorPixel, y: editorHeight - 1 - doorPixel.y }; editorSelection = editorSelection ? { x: editorSelection.x, y: editorHeight - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null; return true; }); } function flipPixelsVertical(pixels, stride, width = stride, height = width) { const source = normalizePixels(pixels, stride); const out = blankPixels(stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[(height - 1 - y) * stride + x] = source[y * stride + x] || null; } return out; } function flipScalarPixelsVertical(values, stride, width = stride, height = width) { const source = normalizeDepthPixels(values, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[(height - 1 - y) * stride + x] = source[y * stride + x] || 0; } return out; } function applyEditorOutline() { commitEditorMutation(() => { const source = [...editorPixels]; const out = [...editorPixels]; let changed = false; for (let y = 0; y < editorHeight; y++) { for (let x = 0; x < editorWidth; x++) { const index = y * editorSize + x; if (source[index]) continue; const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => { const nx = x + dx; const ny = y + dy; return nx >= 0 && ny >= 0 && nx < editorWidth && ny < editorHeight && source[ny * editorSize + nx]; }); if (adjacent) { out[index] = selectedColorCode; changed = true; } } } if (!changed) return false; editorPixels = out; return true; }); } 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]; if (paintTool === 'particle') particleConfig = normalizeParticleConfig({ ...particleConfig, c: code }); renderPalette(); updateParticleUI(); updateSettingsSummary(); } function getActiveEditorPixels() { return editorPixels; } function getDisplayEditorPixels() { return normalizePixels(editorPixels, editorSize); } function displayCellToCanonical(x, y) { return { x, y }; } function canonicalCellToDisplay(x, y) { return { x, y }; } function compactPixelsFromStride(pixels, stride, width, height) { const source = normalizePixels(pixels, stride); const out = blankPixels(width, height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * width + x] = source[y * stride + x] || null; } return out; } function compactDepthFromStride(values, stride, width, height) { const source = normalizeDepthPixels(values, stride); const out = Array(width * height).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * width + x] = source[y * stride + x] || 0; } return out; } function inflatePixelsToStride(pixels, width, height, stride) { const source = normalizePixels(pixels, width, height); const out = blankPixels(stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + x] = source[y * width + x] || null; } return out; } function inflateDepthToStride(values, width, height, stride) { const source = normalizeDepthPixels(values, width, height); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + x] = source[y * width + x] || 0; } return out; } function resizeEditorPlane(source, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, fill = null) { const out = Array(newStride * newStride).fill(fill); const minW = Math.min(oldWidth, newWidth); const minH = Math.min(oldHeight, newHeight); const src = Array.isArray(source) ? source : []; for (let y = 0; y < minH; y++) { for (let x = 0; x < minW; x++) out[y * newStride + x] = src[y * oldStride + x] ?? fill; } return out; } function resizeEditorCanvas(nextWidth, nextHeight) { const newWidth = clampDimension(nextWidth, editorWidth); const newHeight = clampDimension(nextHeight, editorHeight); const newStride = Math.max(newWidth, newHeight); if (newWidth === editorWidth && newHeight === editorHeight && newStride === editorSize) return false; commitEditorMutation(() => { const oldStride = editorSize; const oldWidth = editorWidth; const oldHeight = editorHeight; const nextPixels = resizeEditorPlane(editorPixels, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, null); const nextDepth = resizeEditorPlane(depthPixels, oldStride, oldWidth, oldHeight, newStride, newWidth, newHeight, 0); editorWidth = newWidth; editorHeight = newHeight; editorSize = newStride; editorPixels = normalizePixels(nextPixels, editorSize); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, editorSize, editorWidth, editorHeight); depthPixels = normalizeDepthPixels(nextDepth, editorSize); lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, false); particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < editorWidth && p.y < editorHeight && editorPixels[p.y * editorSize + p.x]); doorPixel = { x: clamp(doorPixel.x, 0, editorWidth - 1), y: clamp(doorPixel.y, 0, editorHeight - 1) }; editorSelection = null; updateDimensionInputs(); resetEditorView(); return true; }); return true; } function setupEditor(sizeOrWidth, rightPixels, leftPixels = null, nextDepthPixels = null, nextHeight = null) { const width = clampDimension(sizeOrWidth, 8); const height = clampDimension(nextHeight ?? sizeOrWidth, width); const stride = Math.max(width, height); editorWidth = width; editorHeight = height; editorSize = stride; const normalizedRight = inflatePixelsToStride(rightPixels, width, height, stride); const normalizedLeft = leftPixels ? inflatePixelsToStride(leftPixels, width, height, stride) : null; const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorEditorPixelsHorizontal(normalizedLeft, stride, width, height); editorPixels = normalizePixels(canonical, stride); editorLeftPixels = mirrorEditorPixelsHorizontal(editorPixels, stride, width, height); editorSelection = null; depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? inflateDepthToStride(nextDepthPixels, width, height, stride) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || stride, stride); updateDimensionInputs(); lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, false); particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < editorWidth && p.y < editorHeight && editorPixels[p.y * editorSize + p.x]); particleConfig = normalizeParticleConfig(particleConfig); doorPixel = { x: clamp(doorPixel.x, 0, editorWidth - 1), y: clamp(doorPixel.y, 0, editorHeight - 1) }; resetEditorView(); drawEditor(); updateEditorHistoryButtons(); } function drawEditor() { clampEditorView(); const canvas = els.paintCanvas; const rectSize = canvas.width; const cell = editorCellSize(); const activeW = editorWidth * cell; const activeH = editorHeight * cell; pctx.clearRect(0, 0, rectSize, rectSize); pctx.fillStyle = '#fffaf0'; pctx.fillRect(0, 0, rectSize, rectSize); pctx.fillStyle = 'rgba(36, 48, 68, .05)'; if (activeW < rectSize) pctx.fillRect(activeW, 0, rectSize - activeW, rectSize); if (activeH < rectSize) pctx.fillRect(0, activeH, rectSize, rectSize - activeH); pctx.save(); pctx.translate(editorView.x, editorView.y); pctx.scale(editorView.zoom, editorView.zoom); const pixels = getDisplayEditorPixels(); for (let y = 0; y < editorHeight; y++) { for (let x = 0; x < editorWidth; 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 < editorHeight; y++) { for (let x = 0; x < editorWidth; x++) { const source = displayCellToCanonical(x, y); const depth = depthPixels[source.y * editorSize + source.x] || 0; if (!depth) continue; 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 <= editorWidth; i++) { const p = Math.round(i * cell) + .5; pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, activeH); pctx.stroke(); } for (let i = 0; i <= editorHeight; i++) { const p = Math.round(i * cell) + .5; pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(activeW, p); pctx.stroke(); } { 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(); } for (const emitter of particlePixels) { const shown = canonicalCellToDisplay(emitter.x, emitter.y); const lx = (shown.x + .5) * cell; const ly = (shown.y + .5) * cell; const color = colorToHex(emitter.c || selectedColorCode); pctx.fillStyle = hexToRgba(color, .20); pctx.fillRect(lx - Math.max(3, cell * .18), ly - Math.max(3, cell * .18), Math.max(6, cell * .36), Math.max(6, cell * .36)); pctx.strokeStyle = color; pctx.lineWidth = 2 / editorView.zoom; pctx.strokeRect(lx - Math.max(2, cell * .12), ly - Math.max(2, cell * .12), Math.max(4, cell * .24), Math.max(4, cell * .24)); } 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 cell = editorCellSize(); const scaledW = editorWidth * cell * editorView.zoom; const scaledH = editorHeight * cell * editorView.zoom; const minX = Math.min(0, size - scaledW); const minY = Math.min(0, size - scaledH); editorView.x = clamp(editorView.x, minX, 0); editorView.y = clamp(editorView.y, minY, 0); } function sanitizeLightPixels(points, pixels, stride, width = stride, height = width, fallbackColor = selectedColorCode, announce = false) { const source = normalizePixels(pixels || [], stride); const maxLights = lightBudgetForArea(width, height); const seen = new Set(); const clean = []; for (const raw of Array.isArray(points) ? points : []) { const x = clampInt(raw.x, 0, width - 1, 0); const y = clampInt(raw.y, 0, height - 1, 0); const key = `${x},${y}`; if (seen.has(key)) continue; if (!source[y * stride + x]) continue; seen.add(key); clean.push({ x, y, c: raw.c || fallbackColor }); if (clean.length >= maxLights) break; } if (announce && clean.length < (Array.isArray(points) ? points.length : 0)) toast(`Light cells limited to ${maxLights} and must sit on non-transparent pixels.`); return clean; } function addLightPixel(x, y, colorCode = selectedColorCode) { const point = { x: clamp(Math.floor(Number(x)), 0, editorWidth - 1), y: clamp(Math.floor(Number(y)), 0, editorHeight - 1), c: colorCode }; if (!editorPixels[point.y * editorSize + point.x]) { toast('Light cells must be placed on painted pixels.'); return; } const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y); if (existing) existing.c = colorCode; else { const maxLights = lightBudgetForArea(editorWidth, editorHeight); if (lightPixels.length >= maxLights) { toast(`Light limit: ${maxLights} for ${editorWidth}テ・{editorHeight} cells.`); return; } lightPixels.push(point); } lightPixels = sanitizeLightPixels(lightPixels, editorPixels, editorSize, editorWidth, editorHeight, selectedColorCode, true); } function removeLightPixel(x, y) { lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y)); } function addParticlePixel(x, y, colorCode = selectedColorCode, dir = particleConfig.dir || 'up') { const point = { x: clamp(Math.floor(Number(x)), 0, editorWidth - 1), y: clamp(Math.floor(Number(y)), 0, editorHeight - 1), c: colorCode, dir: ['up','down','left','right'].includes(dir) ? dir : 'up' }; const existing = particlePixels.find((p) => p.x === point.x && p.y === point.y); if (existing) { existing.c = colorCode; existing.dir = point.dir; } else particlePixels.push(point); } function removeParticlePixel(x, y) { particlePixels = particlePixels.filter((p) => !(p.x === x && p.y === y)); } function mirrorLightPointsHorizontal(points, width, height = width) { return (Array.isArray(points) ? points : []) .map((p) => ({ ...p, x: width - 1 - clampInt(p.x, 0, width - 1, 0) })) .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } function mirrorParticlePointsHorizontal(points, width, height = width) { return (Array.isArray(points) ? points : []) .map((p) => ({ ...p, x: width - 1 - clampInt(p.x, 0, width - 1, 0), dir: p.dir === 'left' ? 'right' : p.dir === 'right' ? 'left' : (p.dir || 'up') })) .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } function shiftParticlePointsVertical(points, width, height = width, shift = 0) { return (Array.isArray(points) ? points : []) .map((p) => ({ ...p, y: clampInt(p.y, 0, height - 1, 0) + shift })) .filter((p) => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height); } function mirrorEditorPixelsHorizontal(pixels, stride, width, height) { const source = normalizePixels(pixels, stride); const out = blankPixels(stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + (width - 1 - x)] = source[y * stride + x] || null; } return out; } function mirrorEditorDepthHorizontal(values, stride, width, height) { const source = normalizeDepthPixels(values, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * stride + (width - 1 - x)] = source[y * stride + x] || 0; } return out; } function normalizeEditorOrientationForSave() { const stride = editorSize; const dynamicLeftCanvas = roleToCategory(currentRole()) === 'dynamic' && editingSide === 'left'; const rightPixels = dynamicLeftCanvas ? mirrorEditorPixelsHorizontal(editorPixels, stride, editorWidth, editorHeight) : normalizePixels(editorPixels, stride); const normalizedDepth = normalizeDepthPixels(depthPixels, stride); const orientedDepth = dynamicLeftCanvas ? mirrorEditorDepthHorizontal(normalizedDepth, stride, editorWidth, editorHeight) : normalizedDepth; const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal(lightPixels, editorWidth, editorHeight) : lightPixels.map((p) => ({ ...p })); const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal(particlePixels, editorWidth, editorHeight) : particlePixels.map((p) => ({ ...p })); const orientedParticleConfig = normalizeParticleConfig({ ...particleConfig, enabled: orientedParticles.length > 0 }, stride); return { rightPixels, depthPixels: orientedDepth, lightPixels: orientedLights, particlePixels: orientedParticles, particleConfig: orientedParticleConfig, mirroredFromLeft: dynamicLeftCanvas, width: editorWidth, height: editorHeight, stride }; } function getBottomShiftRect(pixels, stride, width, height) { let maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) if (pixels[y * stride + x]) maxY = Math.max(maxY, y); } return maxY < 0 ? 0 : height - 1 - maxY; } function shiftPixelsVerticalRect(pixels, stride, width, height, shift) { const out = blankPixels(stride); const source = normalizePixels(pixels, stride); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const ny = y + shift; if (ny >= 0 && ny < height) out[ny * stride + x] = source[y * stride + x] || null; } } return out; } function shiftDepthPixelsVerticalRect(pixels, stride, width, height, shift) { const source = normalizeDepthPixels(pixels, stride); const out = Array(stride * stride).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const ny = y + shift; if (ny >= 0 && ny < height) out[ny * stride + x] = source[y * stride + x] || 0; } } return out; } function trimEditorStateForSave(aligned) { const { stride, width, height } = aligned; let minX = width, minY = height, maxX = -1, maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (!aligned.rightPixels[y * stride + x]) continue; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } if (maxX < 0) return null; const outW = maxX - minX + 1; const outH = maxY - minY + 1; const outPixels = blankPixels(outW, outH); const outDepth = Array(outW * outH).fill(0); for (let y = 0; y < outH; y++) { for (let x = 0; x < outW; x++) { const src = (minY + y) * stride + (minX + x); const dst = y * outW + x; outPixels[dst] = aligned.rightPixels[src] || null; outDepth[dst] = aligned.depthPixels[src] || 0; } } const lightPixels = sanitizeLightPixels( aligned.lightPixels.map((p) => ({ ...p, x: p.x - minX, y: p.y - minY })), outPixels, outW, outW, outH, selectedColorCode, false ); const particlePixels = aligned.particlePixels .map((p) => ({ ...p, x: p.x - minX, y: p.y - minY })) .filter((p) => p.x >= 0 && p.y >= 0 && p.x < outW && p.y < outH && outPixels[p.y * outW + p.x]); return { width: outW, height: outH, size: Math.max(outW, outH), rightPixels: outPixels, depthPixels: outDepth, lightPixels, particlePixels, particleConfig: normalizeParticleConfig({ ...aligned.particleConfig, enabled: particlePixels.length > 0 }, Math.max(outW, outH)), door: aligned.door ? { x: clamp(aligned.door.x - minX, 0, outW - 1), y: clamp(aligned.door.y - minY, 0, outH - 1) } : null, mirroredFromLeft: aligned.mirroredFromLeft, crop: { x: minX, y: minY, w: outW, h: outH } }; } function alignEditorStateToBottom() { const oriented = normalizeEditorOrientationForSave(); const shift = getBottomShiftRect(oriented.rightPixels, oriented.stride, oriented.width, oriented.height); const aligned = { ...oriented, rightPixels: shiftPixelsVerticalRect(oriented.rightPixels, oriented.stride, oriented.width, oriented.height, shift), depthPixels: shiftDepthPixelsVerticalRect(oriented.depthPixels, oriented.stride, oriented.width, oriented.height, shift), lightPixels: oriented.lightPixels .map((p) => ({ ...p, y: p.y + shift })) .filter((p) => p.x >= 0 && p.x < oriented.width && p.y >= 0 && p.y < oriented.height), particlePixels: shiftParticlePointsVertical(oriented.particlePixels, oriented.width, oriented.height, shift), door: { x: clamp(doorPixel.x, 0, oriented.width - 1), y: clamp(doorPixel.y + shift, 0, oriented.height - 1) }, mirroredFromLeft: oriented.mirroredFromLeft }; const trimmed = trimEditorStateForSave(aligned); return trimmed || aligned; } function getBottomShift(pixels, size) { 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(); const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); const asset = { id: existing?.id || uid(), name, category, subtype, size: savedSize, width: aligned.width || savedSize, height: aligned.height || savedSize, createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now(), author: existing?.author || state.authorName || 'Local Artist', ownerAccountId: normalizeOwnerAccountId(existing?.ownerAccountId, currentAccountId()), version: Number(existing?.version || 0) + 1, parentAssetId: existing ? existing.parentAssetId : editParentId, originalAssetId: existing ? existing.originalAssetId : editOriginalId, pixels: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), left: 'mirror' } : null, meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, aligned.width || savedSize, aligned.particlePixels, aligned.rightPixels, aligned.height || savedSize) }; asset.contentHash = computeAssetContentHash(asset); const duplicate = existing ? null : findEquivalentCollectionAsset(asset); if (duplicate) { selectedAssetId = duplicate.id; toast(`${duplicate.name} is already in Collection.`); return duplicate; } if (existing) { const index = state.assets.findIndex((a) => a.id === existing.id); if (index >= 0) state.assets[index] = asset; toast(`${asset.name} updated.`); } else { state.assets.unshift(asset); toast(aligned.mirroredFromLeft ? `${asset.name} saved. Left-facing canvas was mirrored into right-facing movement.` : `${asset.name} saved.`); } selectedAssetId = asset.id; recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset)); editParentId = null; editOriginalId = null; editingAssetId = null; rebuildWorldIndex(); saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); els.lineageNote.textContent = 'Saved as a permanent collection work. Island placement is a separate temporary exhibition object.'; return asset; } function saveAndPlaceFromEditor() { ensureLocalAccount('save-place'); const asset = saveAssetFromEditor(); if (!asset) return; selectedAssetId = asset.id; placementPreview = { asset, x: null, y: null, savedAssetId: asset.id }; clearWorldSelection(false); setMode('place'); setDrawerOpen(false); if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; if (els.openEditor) els.openEditor.hidden = true; toast('Saved to Collection. Click a tile, then choose Place here or Back to canvas.'); } function buildEditorAssetPreview() { const role = currentRole(); const category = roleToCategory(role); const subtype = roleToSubtype(role); const size = editorSize; if (countPixels(editorPixels) < 10) { toast('Draw at least 10 pixels before checking on the island.'); return null; } const aligned = alignEditorStateToBottom(); const savedSize = aligned.size || Math.max(aligned.width || editorWidth, aligned.height || editorHeight); const asset = { id: `preview:${Date.now()}`, name: (els.assetName.value || '').trim() || 'Preview work', category, subtype, size: savedSize, width: aligned.width || savedSize, height: aligned.height || savedSize, author: state.authorName || 'Local Artist', pixels: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels, aligned.width || savedSize, aligned.height || savedSize), left: 'mirror' } : null, meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, aligned.width || savedSize, aligned.particlePixels, aligned.rightPixels, aligned.height || savedSize) }; return asset; } function checkCurrentEditorOnIsland() { const asset = buildEditorAssetPreview(); if (!asset) return; placementPreview = { asset, x: null, y: null }; setDrawerOpen(false); setMode('place'); if (els.placementPreviewBar) els.placementPreviewBar.hidden = false; if (els.openEditor) els.openEditor.hidden = true; toast('Click a valid tile to preview. Then choose Place here or Back to canvas.'); } function cancelPlacementPreview(reopenDrawer = true) { const hadPreview = !!placementPreview; placementPreview = null; if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; if (!reopenDrawer && els.openEditor) els.openEditor.hidden = false; if (reopenDrawer) setDrawerOpen(true); if (hadPreview) render(); } function confirmPreviewPlacement() { if (!placementPreview?.asset || placementPreview.x == null || placementPreview.y == null) { toast('Click a tile first.'); return; } const assetId = placementPreview.savedAssetId || placementPreview.asset.id; if (!findAsset(assetId)) { toast('Saved asset is missing. Back to canvas and save again.'); return; } selectedAssetId = assetId; const { x, y } = placementPreview; placementPreview = null; if (els.placementPreviewBar) els.placementPreviewBar.hidden = true; if (els.openEditor) els.openEditor.hidden = false; placeSelected(x, y); setMode('inspect'); updateSelectionBubble(performance.now()); } function newAsset() { editParentId = null; editOriginalId = null; els.assetName.value = ''; els.assetCategory.value = ''; staticKind = 'nature'; dynamicKind = 'human'; editingSide = 'right'; lightPixels = []; particlePixels = []; particleConfig = { enabled: false, c: selectedColorCode, dir: 'up' }; depthPixels = blankPixels(8).map(() => 0); doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 }; setupEditor(8, blankPixels(8), null); clearEditorHistory(); refreshCategoryUI(); els.lineageNote.textContent = ''; } function hydrateAuthorUI() { state.account = normalizeAccount(state.account); state.authorName = state.account?.name || state.authorName || 'Local Artist'; if (els.authorName) els.authorName.value = state.authorName; updateAccountUI(); } function normalizeAccount(account) { if (!account?.createdAt) return null; const id = String(account.id || `px-${uid()}`).replace(/^local:/, 'px-'); const password = String(account.password || account.pass || generateLocalPassword()); const name = String(account.name || id).trim() || id; return { id, name, password, createdAt: Number(account.createdAt) || Date.now() }; } function generateAccountId() { return `px-${uid().replace(/[^a-z0-9]/gi, '').slice(0, 10)}`; } function generateLocalPassword() { const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; if (globalThis.crypto?.getRandomValues) { const bytes = new Uint8Array(12); globalThis.crypto.getRandomValues(bytes); return [...bytes].map((b) => alphabet[b % alphabet.length]).join(''); } return Array.from({ length: 12 }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join(''); } function createLocalAccount(silent = false) { if (!state.account?.createdAt) { const id = generateAccountId(); state.account = { id, name: id, password: generateLocalPassword(), createdAt: Date.now() }; state.authorName = id; } else { const name = (els.authorName?.value || state.account.name || state.account.id).trim() || state.account.id; state.account = normalizeAccount({ ...state.account, name, password: (els.accountPass?.value || state.account.password || '').trim() || generateLocalPassword() }); state.authorName = state.account.name; } saveState(); updateAccountUI(); renderLibrary(); if (!silent) toast('Local account generated. Change the password after creation.'); return state.account; } function ensureLocalAccount(reason = 'publish') { if (state.account?.createdAt) { state.account = normalizeAccount(state.account); state.authorName = state.account.name; updateAccountUI(); return state.account; } const account = createLocalAccount(true); toast(reason === 'save-place' ? 'Local account auto-generated. You can rename it and should change the password.' : 'Local account auto-generated for publishing. Change the password after creation.'); return account; } function updateAccountUI() { if (!els.accountNote) return; if (!state.account?.createdAt) { if (els.accountId) els.accountId.value = ''; if (els.authorName) els.authorName.value = state.authorName || 'Local Artist'; if (els.accountPass) els.accountPass.value = ''; els.accountNote.textContent = 'Publish creates a local account for island publishing.'; if (els.createAccount) els.createAccount.hidden = false; updatePublishQuotaUI(); return; } state.account = normalizeAccount(state.account); state.authorName = state.account.name; if (els.accountId) els.accountId.value = state.account.id; if (els.authorName) els.authorName.value = state.account.name; if (els.accountPass) els.accountPass.value = state.account.password || ''; const day = getAccountAgeMs() < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+'; els.accountNote.textContent = `Account ${day}.`; if (els.createAccount) els.createAccount.hidden = true; updatePublishQuotaUI(); } function updatePublishQuotaUI() { const quota = getPublishQuotaStatus(); const text = quota.accountRequired ? 'Publish: account needed' : `Publish: ${quota.remaining}/${quota.limit} left`; [els.drawQuotaBadge, els.finishQuotaBadge].filter(Boolean).forEach((badge) => { badge.textContent = text; badge.classList.toggle('quotaEmpty', !quota.accountRequired && quota.remaining <= 0); }); } function focusAssetInWorld(asset) { 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 = ''; if (els.likedCodex) { els.likedCodex.innerHTML = ''; els.likedCodex.hidden = true; } const visibleAssets = state.assets.filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id]); renderLibraryToolbar(visibleAssets); if (!visibleAssets.length) { renderCollectionTabs({ mine: [], others: [], favorite: [] }); els.assetList.append(makeLibraryEmptyNote('No visible assets.')); renderHiddenAssets(); return; } const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter; const matchesSearch = (asset) => { const query = librarySearch.trim().toLowerCase(); if (!query) return true; return [asset.name, asset.author, asset.subtype, asset.category].some((value) => String(value || '').toLowerCase().includes(query)); }; const isMyAsset = (asset) => isCurrentUserAsset(asset); const mine = visibleAssets.filter((asset) => isMyAsset(asset) && matchesFilter(asset) && matchesSearch(asset)); const others = visibleAssets.filter((asset) => !isMyAsset(asset) && matchesFilter(asset) && matchesSearch(asset)); const favorite = getLikedAssets().filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id] && matchesFilter(asset) && matchesSearch(asset)); const groups = { mine, others, favorite }; if (!groups[libraryViewTab]) libraryViewTab = 'mine'; renderCollectionTabs(groups); if (libraryViewTab === 'mine') addLibrarySection('My works', mine, 'No assets in this tab.'); else if (libraryViewTab === 'others') addLibrarySection('Others', others, 'No assets in this tab.'); else addLibrarySection('Favorite', favorite, 'Works you upvote appear here.'); renderHiddenAssets(); } function renderLibraryToolbar(visibleAssets) { if (!els.assetList) return; const toolbar = document.createElement('div'); toolbar.className = 'collectionToolbar'; const summary = document.createElement('div'); summary.className = 'collectionSummary'; const placedCount = visibleAssets.filter((asset) => state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id)).length; summary.textContent = `${visibleAssets.length} works / ${placedCount} on island`; toolbar.append(summary); const search = document.createElement('input'); search.className = 'collectionSearch'; search.type = 'search'; search.placeholder = 'Search collection'; search.value = librarySearch; search.addEventListener('input', () => { librarySearch = search.value || ''; const caret = search.selectionStart || librarySearch.length; renderLibrary(); requestAnimationFrame(() => { const nextSearch = els.assetList?.querySelector('.collectionSearch'); if (!nextSearch) return; nextSearch.focus?.({ preventScroll: true }); nextSearch.setSelectionRange?.(caret, caret); }); }); toolbar.append(search); const chips = document.createElement('div'); chips.className = 'collectionFilters'; for (const filter of ['all', 'human', 'animal', 'bird', 'fish', 'nature', 'building', 'ship', 'other']) { const button = document.createElement('button'); button.type = 'button'; button.className = 'collectionFilter'; button.classList.toggle('active', libraryFilter === filter); button.textContent = filter === 'all' ? 'All' : cap(filter); button.addEventListener('click', () => { const scrollTop = els.drawer?.querySelector('.drawerBody')?.scrollTop; libraryFilter = filter; renderLibrary(); restoreDrawerScroll(scrollTop); }); chips.append(button); } toolbar.append(chips); els.assetList.append(toolbar); } function renderCollectionTabs(groups) { const tabs = document.createElement('div'); tabs.className = 'collectionViewTabs tabs'; const labels = [ ['mine', 'My works'], ['others', 'Others'], ['favorite', 'Favorite'] ]; for (const [key, label] of labels) { const button = document.createElement('button'); button.type = 'button'; button.className = 'tab collectionViewTab'; button.classList.toggle('active', libraryViewTab === key); button.textContent = `${label} ${groups[key]?.length ?? 0}`; button.addEventListener('click', () => { const scrollTop = els.drawer?.querySelector('.drawerBody')?.scrollTop; libraryViewTab = key; renderLibrary(); restoreDrawerScroll(scrollTop); }); tabs.append(button); } els.assetList.append(tabs); } function makeLibraryEmptyNote(text) { const empty = document.createElement('div'); empty.className = 'libraryEmptyNote'; empty.textContent = text; return empty; } function addLibrarySection(title, assets, emptyText = 'No assets in this genre.') { const heading = document.createElement('div'); heading.className = 'librarySectionTitle'; heading.textContent = `${title} (${assets.length})`; heading.title = ''; els.assetList.append(heading); if (!assets.length) { els.assetList.append(makeLibraryEmptyNote(emptyText)); return; } const grid = document.createElement('div'); grid.className = 'assetSectionGrid'; for (const asset of assets) grid.append(makeAssetCard(asset)); els.assetList.append(grid); } function restoreDrawerScroll(scrollTop) { const scroller = els.drawer?.querySelector('.drawerBody'); if (!scroller || !Number.isFinite(scrollTop)) return; scroller.scrollTop = scrollTop; requestAnimationFrame(() => { scroller.scrollTop = scrollTop; }); } function makeAssetCard(asset) { const card = document.createElement('article'); const expanded = asset.id === selectedAssetId; card.className = `assetCard${expanded ? ' selected expanded' : ''}`; 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 title = document.createElement('strong'); title.textContent = asset.name; meta.append(title); const isPlaced = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id); const statusRow = document.createElement('div'); statusRow.className = 'assetStatusRow'; const roleTag = document.createElement('span'); roleTag.className = 'assetStatus role'; roleTag.textContent = cap(subtypeToRole(asset)); const placeTag = document.createElement('span'); placeTag.className = `assetStatus ${isPlaced ? 'placed' : 'stored'}`; placeTag.textContent = isPlaced ? 'On island' : 'Stored'; statusRow.append(roleTag, placeTag); meta.append(statusRow); card.addEventListener('click', () => { const scrollTop = els.drawer?.querySelector('.drawerBody')?.scrollTop; selectedAssetId = expanded ? null : asset.id; updateSelectedLabel(); renderLibrary(); restoreDrawerScroll(scrollTop); if (selectedAssetId) focusAssetInWorld(asset); }); if (expanded) { const lineage = asset.parentAssetId ? ' / derivative' : ''; const span1 = document.createElement('span'); span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${assetWidth(asset)}テ・{assetHeight(asset)}${lineage}`; const span2 = document.createElement('span'); span2.textContent = `Author: ${displayAssetAuthor(asset)} ツキ Remixed: ${getRemixCount(asset.id)}`; meta.append(span1, span2); const actions = document.createElement('div'); actions.className = 'assetActions'; const assetVotes = getAssetVoteCounts(asset.id); const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0; const up = makeButton(`笆イ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); }); const down = makeButton(`笆シ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); }); up.classList.toggle('activeVote', assetPreviousVote > 0); down.classList.toggle('activeVote', assetPreviousVote < 0); up.classList.toggle('mutedVote', assetPreviousVote < 0); down.classList.toggle('mutedVote', assetPreviousVote > 0); const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); }); hide.classList.toggle('mutedAction', assetPreviousVote >= 0); const isMine = isCurrentUserAsset(asset); const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); }); const edit = isMine ? makeButton('Edit', (event) => { event?.stopPropagation?.(); editOriginalAsset(asset); }) : null; const move = isMine ? makeButton('Place/Move', (event) => { event?.stopPropagation?.(); selectedAssetId = asset.id; updateSelectedLabel(); renderLibrary(); setMode('place'); setDrawerOpen(false); toast('Click a valid tile to place or move it.'); }) : null; const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); actions.append(up, down, hide, remix); if (edit) actions.append(edit); if (move) actions.append(move); if (isMine) actions.append(del); meta.append(actions); } else { const mini = document.createElement('span'); mini.textContent = `${cap(asset.subtype)} ツキ ${assetWidth(asset)}テ・{assetHeight(asset)}`; meta.append(mini); if (isCurrentUserAsset(asset)) { const quickActions = document.createElement('div'); quickActions.className = 'assetActions'; const del = makeButton('Delete', (event) => { event?.stopPropagation?.(); deleteAsset(asset); }); del.classList.add('danger'); quickActions.append(del); meta.append(quickActions); } } card.append(preview, meta); return card; } function getLikedAssets() { const voter = currentVoterKey(); const likedIds = new Set(); for (const [assetId, votes] of Object.entries(state.assetVotes || {})) { if ((votes?.voters || {})[voter] > 0) likedIds.add(assetId); } for (const [objectId, votes] of Object.entries(state.objectVotes || {})) { if ((votes?.voters || {})[voter] > 0) { const obj = [...(state.placed || []), ...(state.dynamicSummons || [])].find((item) => item.id === objectId); if (obj?.assetId) likedIds.add(obj.assetId); } } return [...likedIds].map(findAsset).filter(Boolean); } function renderLikedCodex() { if (!els.likedCodex) return; const liked = getLikedAssets().slice(0, 12); if (!liked.length) { els.likedCodex.innerHTML = '
Favorite
Works you upvote appear here.
'; return; } els.likedCodex.innerHTML = '
Favorite
'; const list = els.likedCodex.querySelector('.likedCodexList'); for (const asset of liked) { const item = document.createElement('button'); item.type = 'button'; item.className = 'likedCodexItem'; const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; drawPreview(canvas, asset); const label = document.createElement('span'); label.textContent = asset.name; item.append(canvas, label); item.addEventListener('click', () => { selectedAssetId = asset.id; updateSelectedLabel(); focusAssetInWorld(asset); }); list.append(item); } } 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 collection works'; 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 hiddenReasonLabel(object) { if (!object) return 'Hidden locally'; if (object.status === 'hidden_rotation') return 'Not shown on island: exhibition is full. Works remain in your Collection.'; if (object.status === 'permanent_hidden' || object.status === 'violation_hidden') return 'Hidden by moderation. This work is not visible to authors or viewers.'; if (object.hiddenReason === 'rotation_cap' || object.hiddenReason === 'extreme_downvotes') return 'Not shown on island: exhibition is full. Works remain in your Collection.'; return 'Hidden locally'; } 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 ? `Hidden locally after report: ${report.reason}` : hiddenReasonLabel(object); const actions = document.createElement('div'); actions.className = 'assetActions'; const restore = makeButton('Show again', (event) => { event.stopPropagation(); if (isPermanentlyHiddenObject(object)) { toast('This object is permanently hidden.'); return; } if (!canPublishObject('republish')) return; delete state.hiddenObjects[object.id]; recordObjectPublish(kind, object, 'republish'); saveState(); hydrateRuntime(); renderLibrary(); updateSelectionBubble(performance.now()); toast('Object republished.'); }); 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: ${displayAssetAuthor(asset)}`; 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 w = assetWidth(asset); const h = assetHeight(asset); const scale = Math.floor(48 / Math.max(w, h)) || 1; const ox = Math.floor((canvas.width - w * scale) / 2); const oy = Math.floor((canvas.height - h * scale) / 2); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const color = pixels[y * w + x]; if (!color) continue; c.fillStyle = colorToHex(color); c.fillRect(ox + x * scale, oy + y * scale, scale, scale); } } } function renderPalette() { if (!els.paletteGrid) return; els.paletteGrid.innerHTML = ''; const visiblePalette = advancedDraw ? PALETTE : PALETTE.slice(0, BASIC_PALETTE_COUNT); for (const entry of visiblePalette) { 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; const choose = (event) => { event.preventDefault(); event.stopPropagation(); selectPaletteCode(entry.code); drawEditor(); }; swatch.addEventListener('pointerdown', choose); swatch.addEventListener('click', (event) => event.preventDefault()); els.paletteGrid.append(swatch); } els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b'; } 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 loadAssetIntoEditor(asset, mode = 'remix') { setDrawerOpen(true); setTab('draw'); const editExisting = mode === 'edit'; editingAssetId = editExisting ? asset.id : null; editParentId = editExisting ? asset.parentAssetId : asset.id; editOriginalId = editExisting ? asset.originalAssetId : (asset.originalAssetId || asset.id); els.assetName.value = editExisting ? (asset.name || 'Untitled') : `${asset.name} Remix`; els.assetCategory.value = subtypeToRole(asset); staticKind = asset.category === 'static' ? asset.subtype : staticKind; dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind; editingSide = 'right'; const right = getAssetPixels(asset, 'right'); lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode })); particlePixels = (asset.meta?.particlePixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || selectedColorCode, dir: p.dir || 'up' })); const aw = assetWidth(asset); const ah = assetHeight(asset); particleConfig = normalizeParticleConfig((particlePixels.length ? { enabled: true, c: particlePixels[0].c || selectedColorCode, dir: particlePixels[0].dir || 'up' } : { enabled: false, c: selectedColorCode, dir: 'up' }), Math.max(aw, ah)); depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], aw, ah); doorPixel = asset.meta?.door || { x: Math.floor(aw / 2), y: ah - 1 }; setupEditor(aw, right, null, depthPixels, ah); clearEditorHistory(); refreshCategoryUI(); els.lineageNote.textContent = editExisting ? `Editing 窶・{asset.name}窶・ Save updates this collection work.` : `Remixing 窶・{asset.name}窶・ Save creates a separate new collection work.`; } function editOriginalAsset(asset) { loadAssetIntoEditor(asset, 'edit'); } function remixEdit(asset) { loadAssetIntoEditor(asset, 'remix'); } function copyEdit(asset) { remixEdit(asset); } function assetMatchesDeleteTarget(candidate, target) { if (!candidate || !target) return false; if (candidate.id === target.id) return true; if (target.contentHash && candidate.contentHash === target.contentHash) return true; if ((candidate.name || '') === (target.name || '') && (candidate.category || '') === (target.category || '') && (candidate.subtype || '') === (target.subtype || '') && assetWidth(candidate) === assetWidth(target) && assetHeight(candidate) === assetHeight(target)) return true; return false; } function getDefaultGalleryAssetNames() { if (!defaultGalleryAssetNames) { defaultGalleryAssetNames = new Set(buildDefaultGalleryPack(Date.now()).assets.map((asset) => asset.name).filter(Boolean)); } return defaultGalleryAssetNames; } function isSampleGalleryAsset(asset) { if (!asset) return false; return asset.meta?.galleryOrigin === 'default-gallery' || getDefaultGalleryAssetNames().has(asset.name || ''); } async function deleteAsset(asset) { if (!asset) return; const deletedAssetIds = new Set([asset.id]); await deleteAssetIds(deletedAssetIds, `"${asset.name}"`, asset); } async function deleteAssetIds(deletedAssetIds, label = 'selected works', permissionAsset = null) { deletedAssetIds = new Set([...deletedAssetIds].filter(Boolean)); if (!deletedAssetIds.size) return; const assetsToDelete = (state.assets || []).filter((candidate) => deletedAssetIds.has(candidate.id)); if (!assetsToDelete.length) return; if (assetsToDelete.some((candidate) => !canCurrentAccountDeleteAsset(candidate, false))) { canCurrentAccountDeleteAsset(assetsToDelete.find((candidate) => !canCurrentAccountDeleteAsset(candidate, false)), true); return; } const used = state.placed.some((p) => deletedAssetIds.has(p.assetId)) || state.dynamicSummons.some((p) => deletedAssetIds.has(p.assetId)); const usageText = used ? ' Island placements that use it will also be removed.' : ''; const confirmed = await confirmWebsiteDialog({ title: 'Delete work', message: `Delete ${label}?${usageText}`, okText: 'Delete', danger: true }); if (!confirmed) return; const sharedDelete = isSharedWorld(); if (sharedDelete) { for (const assetId of deletedAssetIds) queueSharedCommand(makeSharedCommand('asset.delete', { assetId }), { toast: false }); toast('Delete queued for server validation.'); } const removedObjectIds = new Set(); const removedObjectAssetIds = new Map(); for (const placed of state.placed || []) { if (!deletedAssetIds.has(placed.assetId)) continue; removedObjectIds.add(placed.id); removedObjectAssetIds.set(placed.id, placed.assetId); } for (const summon of state.dynamicSummons || []) { if (!deletedAssetIds.has(summon.assetId)) continue; removedObjectIds.add(summon.id); removedObjectAssetIds.set(summon.id, summon.assetId); } ensureWorldProtectionState(); for (const assetId of deletedAssetIds) { const deleted = state.assets.find((candidate) => candidate.id === assetId) || permissionAsset || assetsToDelete[0]; state.tombstones.assets[assetId] = { id: assetId, name: deleted.name || '', deletedAt: Date.now(), deletedBy: currentAccountId() || 'local', ownerAccountId: normalizeOwnerAccountId(deleted.ownerAccountId), author: deleted.author || '', version: Number(deleted.version) || 1 }; const deletedName = deleted.name || ''; if (deletedName && !state.deletedSeedAssetNames.includes(deletedName)) state.deletedSeedAssetNames.push(deletedName); } for (const id of removedObjectIds) { state.tombstones.objects[id] = { id, assetId: removedObjectAssetIds.get(id) || permissionAsset?.id || assetsToDelete[0]?.id || '', deletedAt: Date.now(), deletedBy: currentAccountId() || 'local' }; } state.assets = state.assets.filter((a) => !deletedAssetIds.has(a.id)); state.placed = state.placed.filter((p) => !deletedAssetIds.has(p.assetId)); state.dynamicSummons = state.dynamicSummons.filter((p) => !deletedAssetIds.has(p.assetId)); for (const assetId of deletedAssetIds) { delete state.assetVotes?.[assetId]; delete state.hiddenAssets?.[assetId]; } for (const id of removedObjectIds) { delete state.objectVotes?.[id]; delete state.hiddenObjects?.[id]; } state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); if (deletedAssetIds.has(selectedAssetId)) selectedAssetId = state.assets[0]?.id ?? null; if (selectedObject && (deletedAssetIds.has(selectedObject.assetId) || removedObjectIds.has(selectedObject.id))) selectedObject = null; if (!sharedDelete) { for (const assetId of deletedAssetIds) recordSyncEvent(Phase2Sync?.createAssetDeleteEvent?.(assetId)); } saveState(); spriteCache.clear(); hydrateRuntime(); renderLibrary(); updateSelectedLabel(); updateSelectionBubble(performance.now()); toast(`${label} deleted.`); } function hydrateRuntime() { const authoritativeMotion = isSharedWorld() && isServerAuthoritative('dynamicMotion'); 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); const synced = authoritativeMotion ? (state.serverSync?.dynamicTargets?.[summon.id] || summon.serverState || {}) : {}; const startX = Number.isFinite(Number(synced.x)) ? Number(synced.x) : (spawn.x + .5); const startY = Number.isFinite(Number(synced.y)) ? Number(synced.y) : (spawn.y + .5); const targetX = Number.isFinite(Number(synced.targetX)) ? Number(synced.targetX) : startX; const targetY = Number.isFinite(Number(synced.targetY)) ? Number(synced.targetY) : startY; const facing = Number.isFinite(Number(synced.facing)) && Number(synced.facing) !== 0 ? Math.sign(Number(synced.facing)) : 1; return { id: summon.id, assetId: summon.assetId, homeX: summon.homeX, homeY: summon.homeY, x: startX, y: startY, targetX, targetY, vx: 0, lastMoveX: 0, facing, idleUntil: authoritativeMotion ? 0 : performance.now() + 700 + Math.random() * 1600, hiddenUntil: 0, seed: Math.random() * 9999, nextDecisionAt: 0, nextBubbleAt: 800 + Math.random() * 1500, nextStepParticleAt: performance.now() + 300 + Math.random() * 280, serverMotion: authoritativeMotion }; }).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 applyServerDrivenMotion(item, asset, dt, time) { const synced = state.serverSync?.dynamicTargets?.[item.id] || null; if (synced) { if (Number.isFinite(Number(synced.homeX))) item.homeX = Number(synced.homeX); if (Number.isFinite(Number(synced.homeY))) item.homeY = Number(synced.homeY); if (Number.isFinite(Number(synced.targetX))) item.targetX = Number(synced.targetX); if (Number.isFinite(Number(synced.targetY))) item.targetY = Number(synced.targetY); else { item.targetX = Number.isFinite(Number(synced.x)) ? Number(synced.x) : item.targetX; item.targetY = Number.isFinite(Number(synced.y)) ? Number(synced.y) : item.targetY; } if (Number.isFinite(Number(synced.facing)) && Number(synced.facing) !== 0) item.facing = Math.sign(Number(synced.facing)); } else { item.targetX = item.homeX + .5; item.targetY = item.homeY + .5; } const dx = item.targetX - item.x; const dy = item.targetY - item.y; const len = Math.hypot(dx, dy); if (len < 0.001) { item.vx = 0; item.lastMoveX = 0; return; } const speed = ({ human: .85, animal: .74, fish: .55, bird: .8 }[asset.subtype] || .6) * dt; const step = Math.min(speed, len); const moveX = (dx / len) * step; const moveY = (dy / len) * step; if (Math.abs(moveX) > 0.002) item.facing = Math.sign(moveX); item.vx = Math.abs(moveX) > 0.002 ? Math.sign(moveX) : 0; item.lastMoveX = moveX; item.x += moveX; item.y += moveY; if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) { const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1)); if (groundTile && groundTile.type !== 'water') spawnGroundStepParticles(item, time, groundTile); item.nextStepParticleAt = time + 300 + Math.random() * 320; } } 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 (item.serverMotion) { applyServerDrivenMotion(item, asset, dt, time); continue; } if (time < (item.idleUntil || 0)) { item.vx = 0; item.lastMoveX = 0; continue; } const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y); if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time); if (time < (item.idleUntil || 0)) { item.vx = 0; item.lastMoveX = 0; continue; } 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); const moveX = (dx / len) * step; const moveY = (dy / len) * step; if (Math.abs(moveX) > 0.003) item.facing = Math.sign(moveX); item.vx = Math.abs(moveX) > 0.003 ? Math.sign(moveX) : 0; item.lastMoveX = moveX; item.x += moveX; item.y += moveY; if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) { const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1)); if (groundTile && groundTile.type !== 'water') { spawnGroundStepParticles(item, time, groundTile); } item.nextStepParticleAt = time + 300 + Math.random() * 320; } 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.lastMoveX = 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); lastRenderAt = 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(); if (time - lastRenderAt >= TARGET_RENDER_FRAME_MS) { render(time); lastRenderAt = time; } scheduleFrame(); } function updateClock() { const phase = getPhase(); const progress = phase.progress || 0; const degrees = progress * 360; if (els.analogClockHand) els.analogClockHand.style.transform = `translate(-50%, -100%) rotate(${degrees.toFixed(2)}deg)`; if (els.analogClock) { els.analogClock.style.setProperty('--clock-rotate', `${degrees.toFixed(2)}deg`); els.analogClock.setAttribute('aria-label', `${phase.label} island clock`); els.analogClock.title = `${phase.label} ツキ 10 min = 1 island day`; } const second = Math.floor(getAuthoritativeNow() / 1000); if (second !== lastClockSecond) { lastClockSecond = second; if (els.phaseLabel) els.phaseLabel.textContent = phase.label; } if (els.phaseBar) els.phaseBar.style.width = `${Math.round(progress * 100)}%`; } function getShadowForMinute(minute) { if (Lighting?.getShadowForMinute) return Lighting.getShadowForMinute(minute); const m = clamp(Number(minute) || 0, 0, 10); const smooth = (t) => clamp(t, 0, 1) * clamp(t, 0, 1) * (3 - 2 * clamp(t, 0, 1)); let dirX = -0.9; let length = 1.2; let alpha = 0; let scaleY = 0.26; if (m < 0.65) { // Pre-dawn: moon shadow fades out completely before morning begins. const t = smooth(m / 0.65); dirX = lerp(0.42, 0.18, t); length = lerp(1.05, 0.92, t); alpha = lerp(0.12, 0.0, t); scaleY = lerp(0.22, 0.18, t); } else if (m < 1.35) { // Morning: sunlight shadow appears from zero. const t = smooth((m - 0.65) / 0.70); dirX = lerp(-1.05, -0.82, t); length = lerp(1.38, 1.12, t); alpha = lerp(0.0, 0.27, t); scaleY = lerp(0.22, 0.27, t); } else if (m < 5.65) { // Day: rotate gradually with the sun, no hard switch in the evening. const t = smooth((m - 1.35) / 4.30); const elevation = Math.sin(t * Math.PI); dirX = lerp(-0.82, 0.98, t); length = lerp(1.12, 1.34, t) - elevation * 0.48; alpha = lerp(0.27, 0.20, elevation); scaleY = 0.24 + 0.06 * (1 - elevation); } else if (m < 6.40) { // Evening: keep the same sun-side direction and fade to zero before night. const t = smooth((m - 5.65) / 0.75); dirX = lerp(0.98, 1.08, t); length = lerp(1.34, 1.56, t); alpha = lerp(0.20, 0.0, t); scaleY = lerp(0.28, 0.20, t); } else if (m < 7.25) { // Night starts after the sunlight shadow disappears; moon shadow fades in. const t = smooth((m - 6.40) / 0.85); dirX = lerp(0.20, -0.36, t); length = lerp(0.92, 1.08, t); alpha = lerp(0.0, 0.12, t); scaleY = lerp(0.18, 0.23, t); } else if (m < 9.20) { const t = smooth((m - 7.25) / 1.95); dirX = lerp(-0.36, -0.58, t); length = lerp(1.08, 1.18, t); alpha = 0.12; scaleY = 0.23; } else { // Before dawn: moon shadow darkens/fades out to zero, then morning creates a new sun shadow. const t = smooth((m - 9.20) / 0.80); dirX = lerp(-0.58, 0.42, t); length = lerp(1.18, 1.05, t); alpha = lerp(0.12, 0.0, t); scaleY = lerp(0.23, 0.18, t); } return { dirX, length, skewX: dirX * (0.72 + 0.32 * length), scaleY, alpha }; } function getPhase() { const authorityNow = getAuthoritativeNow(); const dayMs = Number(state.serverSync?.clock?.dayMs) || DAY_MS; if (Lighting?.getPhase) return Lighting.getPhase({ dayMs, now: authorityNow, dayNightEnabled: visualSettings().enableDayNight !== false, mixHex }); if (visualSettings().enableDayNight === false) { return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, darkOverlay: 'rgba(12, 19, 45, 0)', tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) }; } const t = mod(authorityNow, dayMs); const minute = t / 60000; const stops = [ { at: 0.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] }, { at: 0.65, key: 'dawn', label: 'Morning', sky: '#5969aa', darkness: 0.30, tint: [120, 100, 150, 0.10], overlay: [40, 43, 78] }, { at: 1.35, key: 'morning', label: 'Morning', sky: '#f6b16b', darkness: 0.12, tint: [255, 195, 126, 0.13], overlay: [114, 64, 36] }, { at: 2.15, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, { at: 4.75, key: 'day', label: 'Day', sky: '#8ad8ff', darkness: 0.00, tint: [255, 255, 255, 0.00], overlay: [12, 19, 45] }, { at: 5.65, key: 'evening', label: 'Evening', sky: '#ffa153', darkness: 0.07, tint: [255, 166, 88, 0.10], overlay: [130, 48, 18] }, { at: 6.40, key: 'evening', label: 'Evening', sky: '#d55a2d', darkness: 0.20, tint: [255, 118, 62, 0.17], overlay: [118, 42, 18] }, { at: 7.25, key: 'night', label: 'Night', sky: '#1b2a58', darkness: 0.46, tint: [32, 40, 82, 0.07], overlay: [12, 19, 45] }, { at: 9.20, key: 'night', label: 'Night', sky: '#132247', darkness: 0.58, tint: [16, 24, 58, 0.10], overlay: [12, 19, 45] }, { at: 10.00, key: 'preDawn', label: 'Night', sky: '#17254e', darkness: 0.52, tint: [22, 28, 66, 0.10], overlay: [12, 19, 45] } ]; let a = stops[0], b = stops[1]; for (let i = 0; i < stops.length - 1; i++) { if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; } } 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 overlay = a.overlay.map((v, i) => lerp(v, b.overlay[i], eased)); const darkness = lerp(a.darkness, b.darkness, eased); const mixSky = mixHex(a.sky, b.sky, eased); const dominantKey = darkness >= 0.34 ? 'night' : (a.key === 'evening' || b.key === 'evening' ? 'evening' : (a.key === 'morning' || b.key === 'morning' || a.key === 'dawn' || b.key === 'dawn' ? 'morning' : 'day')); const label = dominantKey === 'night' ? 'Night' : dominantKey === 'evening' ? 'Evening' : dominantKey === 'morning' ? 'Morning' : 'Day'; return { key: dominantKey, label, progress: t / dayMs, sky: mixSky, darkness, darkOverlay: `rgba(${Math.round(overlay[0])}, ${Math.round(overlay[1])}, ${Math.round(overlay[2])}, ${darkness.toFixed(3)})`, tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`, tintAlpha: tint[3], shadow: getShadowForMinute(minute) }; } function areNightLightsActive(phase = renderPhase) { return visualSettings().enableLights !== false && phase?.key === 'night'; } function collectLightSourcesForItems(items, time, phase) { if (!areNightLightsActive(phase) || !items?.length) return []; const sources = []; for (const item of items) { const asset = item.asset; if (!asset.meta?.hasLight || !asset.meta.lightPixels?.length) continue; const info = getSpriteDrawInfo(item, time, true); const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const pivotX = info.drawX + info.sprite.width / 2; const pivotY = info.drawY + info.sprite.height * 0.8; const aw = assetWidth(asset); for (const light of asset.meta.lightPixels) { const lx = asset.category === 'dynamic' && info.side === 'left' ? aw - 1 - light.x : light.x; let wx = info.drawX + (lx + 0.5) * scale; let wy = info.drawY + (light.y + 0.5) * scale; if (info.angle) { const dx = wx - pivotX; const dy = wy - pivotY; const cos = Math.cos(info.angle); const sin = Math.sin(info.angle); wx = pivotX + dx * cos - dy * sin; wy = pivotY + dx * sin + dy * cos; } sources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(13, 22, assetMaxSize(asset) / 64), intensity: clamp(0.82 + assetMaxSize(asset) / 96, 0.85, 1.35), ownerId: item.source?.id || asset.id, flickerSeed: parseInt(fnv1a(`${item.source?.id || asset.id}:${light.x},${light.y}`).slice(0, 6), 16) || 1 }); } } return sources; } function updateCameraFollowSelected() { if (!cameraFollowSelected || !selectedObject) return; const selected = getSelectedWorldPosition(); if (!selected) return; const targetX = cw / 2 - selected.pos.x * view.zoom; const targetY = ch * 0.52 - (selected.pos.y - Math.max(18, assetMaxSize(selected.asset) * 0.55)) * view.zoom; view.x = lerp(view.x, targetX, 0.24); view.y = lerp(view.y, targetY, 0.24); } function ensureShadowMaskCanvas() { if (!shadowMaskCanvas) { shadowMaskCanvas = document.createElement('canvas'); shadowMaskCtx = shadowMaskCanvas.getContext('2d', { alpha: true }); } const w = Math.max(1, Math.ceil(cw * dpr)); const h = Math.max(1, Math.ceil(ch * dpr)); if (shadowMaskCanvas.width !== w || shadowMaskCanvas.height !== h) { shadowMaskCanvas.width = w; shadowMaskCanvas.height = h; } shadowMaskCtx.setTransform(1, 0, 0, 1, 0, 0); shadowMaskCtx.clearRect(0, 0, shadowMaskCanvas.width, shadowMaskCanvas.height); shadowMaskCtx.setTransform(dpr, 0, 0, dpr, 0, 0); shadowMaskCtx.translate(view.x, view.y); shadowMaskCtx.scale(view.zoom, view.zoom); shadowMaskCtx.imageSmoothingEnabled = false; return shadowMaskCtx; } function beginProjectedShadowLayer(phase) { const alpha = phase?.shadow?.alpha || 0; if (alpha <= 0.001) return false; activeShadowCtx = ensureShadowMaskCanvas(); activeShadowCtx.globalCompositeOperation = 'source-over'; return true; } function endProjectedShadowLayer(phase) { if (!activeShadowCtx || !shadowMaskCanvas) return; activeShadowCtx = null; ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.globalAlpha = phase?.shadow?.alpha || 0.16; ctx.imageSmoothingEnabled = false; ctx.drawImage(shadowMaskCanvas, 0, 0, cw, ch); ctx.restore(); } function render(time = performance.now()) { const phase = getPhase(); renderPhase = phase; updateCameraFollowSelected(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cw, ch); ctx.fillStyle = phase.sky || '#86d5ff'; ctx.fillRect(0, 0, cw, ch); ctx.save(); ctx.translate(view.x, view.y); ctx.scale(view.zoom, view.zoom); ctx.imageSmoothingEnabled = false; lastRenderedSpriteInfo.clear(); const visibleItems = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN)); const activeLights = collectLightSourcesForItems(visibleItems, time, phase); const lightSources = [...activeLights]; drawTerrainCache(terrainCache); drawTerrainProjectedShadows(phase); drawObjectsFromItems(visibleItems, time, activeLights, phase, true); drawHoverTile(); if (visualSettings().enableParticles) { spawnConfiguredParticleEmitters(visibleItems, time); drawNatureDriftParticles(time); drawLandStepParticles(time); drawBubbleParticles(time); drawSpawnEffects(time); drawConfettiParticles(time); } const nightLightsActive = areNightLightsActive(phase); if (nightLightsActive) { drawWaterLightReflections(lightSources, time, phase); } 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 = phase.darkOverlay || `rgba(12, 19, 45, ${phase.darkness})`; ctx.fillRect(0, 0, cw, ch); } if (nightLightsActive && phase.darkness > 0) drawLightSources(lightSources, phase.darkness, time); if (nightLightsActive) drawNightCursorGlow(time, phase); } function drawTerrainProjectedShadows(phase) { const shadow = phase?.shadow || { alpha: .16, length: 1, skewX: 0, scaleY: 0.28 }; if ((shadow.alpha || 0) <= 0.001) return; const c = activeShadowCtx || ctx; const rect = getViewportWorldRect(2); c.save(); c.globalAlpha = activeShadowCtx ? 1 : shadow.alpha; c.fillStyle = '#1b1f26'; c.imageSmoothingEnabled = false; for (let ty = Math.max(0, Math.floor(rect.minY) - 2); ty <= Math.min(WORLD_H - 1, Math.ceil(rect.maxY) + 2); ty++) { for (let tx = Math.max(0, Math.floor(rect.minX) - 2); tx <= Math.min(WORLD_W - 1, Math.ceil(rect.maxX) + 2); tx++) { const tile = world.get(tx, ty); if (!tile || tile.type !== 'highland') continue; const lift = getTileLift(tile); if (lift <= 0) continue; const front = world.get(tx, ty + 1); const right = world.get(tx + 1, ty); const left = world.get(tx - 1, ty); const needsShadow = !front || front.type === 'water' || getTileLift(front) < lift || !right || getTileLift(right) < lift || !left || getTileLift(left) < lift; if (!needsShadow) continue; const pos = tileToWorld(tx, ty); const cx = pos.x; const cy = pos.y + TILE_H * 0.72; c.save(); c.translate(cx, cy); c.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0); c.beginPath(); c.moveTo(0, -TILE_H * 0.42); c.lineTo(TILE_W * 0.48, 0); c.lineTo(0, TILE_H * 0.42); c.lineTo(-TILE_W * 0.48, 0); c.closePath(); c.fill(); c.restore(); } } c.restore(); } function drawHoverTile() { if (!hoverTile) return; const { x, y } = tileToWorld(hoverTile.x, hoverTile.y); const hoverTerrain = world.get(hoverTile.x, hoverTile.y); const lift = getTileLift(hoverTerrain); ctx.beginPath(); ctx.moveTo(x, y - lift); ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); 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 drawNightCursorGlow(time, phase) { if (!areNightLightsActive(phase) || !cursorScreen.active) return; const cursorPoint = getCursorLightScreenPoint(phase); if (!cursorPoint) return; const sx = cursorPoint.x; const sy = cursorPoint.y; const pulse = 0.9 + Math.sin(time / 420) * 0.1; const radius = Math.max(28, 54 * view.zoom) * pulse; ctx.save(); ctx.globalCompositeOperation = 'screen'; const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius); glow.addColorStop(0, 'rgba(198, 236, 255, .16)'); glow.addColorStop(0.22, 'rgba(170, 225, 255, .09)'); glow.addColorStop(0.6, 'rgba(142, 218, 255, .035)'); glow.addColorStop(1, 'rgba(142, 218, 255, 0)'); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(sx, sy, radius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } function drawSpawnEffects(time) { if (!spawnEffects.length) return; spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650); 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 drawObjectsFromItems(items, time, activeLights, phase, drawShadows = true) { for (const item of items) { if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') { drawSpriteItem(item, time, activeLights, true, phase, drawShadows); } } for (const item of items) { if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) { drawSpriteItem(item, time, activeLights, false, phase, drawShadows); } } } function getHighlandWallFaceEntries(tile, worldData) { if (!tile || tile.type !== 'highland') return []; const lift = getTileLift(tile); const out = []; const add = (side, nx, ny) => { const neighbor = worldData.get(nx, ny); if (neighbor && getTileLift(neighbor) >= lift) return; const p = tileToWorld(nx, ny); const neighborSurfaceDepth = neighbor ? p.y - getTileLift(neighbor) : tileToWorld(tile.x, tile.y).y + TILE_H / 2; out.push({ side, depth: neighborSurfaceDepth - 0.06 }); }; add('left', tile.x, tile.y + 1); add('right', tile.x + 1, tile.y); return out; } 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; let stretchX = 1; let stretchY = 1; let alpha = 1; let side = 'right'; let angle = 0; if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) { bob += Math.sin(time / 900 + item.x * .7) * 1.6; if (asset.subtype === 'ship') { const drift = Math.sin(time / 2400 + item.x * 0.37 + item.y * 0.19) * 5.2; const glide = Math.cos(time / 3200 + item.x * 0.21) * 2.6; pos.x += drift; pos.y += glide * 0.22; angle += Math.sin(time / 1400 + item.x * 0.2) * 0.035; } } if (asset.category === 'dynamic') { const runtime = item.source || {}; const seed = Number.isFinite(runtime.seed) ? runtime.seed : 0; const isPreview = Boolean(item.preview || runtime.preview); const moveFacing = Math.abs(runtime.lastMoveX || 0) > 0.003 ? Math.sign(runtime.lastMoveX) : (runtime.facing || runtime.vx || 1); side = isPreview ? 'right' : (moveFacing < 0 ? 'left' : 'right'); if (asset.subtype === 'human' || asset.subtype === 'animal') { const idle = isPreview || time < (runtime.idleUntil || 0); if (!idle) { const jumpPhase = time / 78 + seed * 0.9; const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15); const liftNow = getLiftAtCoord(item.x, item.y); const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY); const terrainBoost = Math.abs(liftTarget - liftNow) / 12; bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0)); const cycle = Math.floor(jumpPhase / (Math.PI * 0.9)); const rand = pseudoNoise(cycle + seed * 13.17); angle += (rand - 0.5) * 0.24 * hop; const land = Math.pow(Math.max(0, Math.cos(jumpPhase * 2)), 10) * Math.max(0, 1 - hop); stretchX += land * 0.18; stretchY -= land * 0.12; } } if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + seed) * 4.5; if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + seed) * 1.7; } } if (includeSelectBounce && selectedObject?.id === item.source?.id) { const selectionElapsed = time - (selectedObject.selectedAt || 0); const selectedT = clamp(selectionElapsed / 560, 0, 1); if (selectionElapsed < 700) { const bounce = selectedT < 1 ? Math.sin(selectedT * Math.PI) : 0; bob += -bounce * (asset.category === 'dynamic' ? 9 : 8); const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1; const tiltRand = pseudoNoise(tiltSeed * 0.013) - 0.5; angle += tiltRand * 0.32 * bounce; // Squash/stretch lands later than the vertical bounce peak. const landT = clamp((selectionElapsed - 300) / 560, 0, 1); const land = Math.exp(-Math.pow((landT - 0.88) / 0.085, 2)); stretchX += land * 0.30; stretchY -= land * 0.21; } } 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, stretchX, stretchY }; } function drawSpriteItem(item, time, lightSources, underwater, phase, drawShadow = true) { const info = getSpriteDrawInfo(item, time, true); const localLights = getLocalSpriteLights(info, phase); const litSprite = localLights.length ? getSpriteCanvas(info.asset, info.side, localLights) : info.sprite; info.sprite = litSprite; const { asset, pos, sprite, drawX, drawY, alpha, angle, stretchX = 1, stretchY = 1 } = info; lastRenderedSpriteInfo.set(item.source?.id || asset.id, { ...info, time }); if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id, asset); if (drawShadow && !(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(info, phase); ctx.save(); ctx.globalAlpha = alpha; const pivotX = Math.round(drawX + sprite.width / 2); const pivotY = Math.round(drawY + sprite.height * 0.8); if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) { ctx.translate(pivotX, pivotY); if (angle) ctx.rotate(angle); if (Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.scale(stretchX, stretchY); ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8)); } else { ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY)); } if (underwater) { ctx.fillStyle = 'rgba(103, 181, 217, .18)'; if (angle || Math.abs(stretchX - 1) > 0.001 || Math.abs(stretchY - 1) > 0.001) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height); else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height); } ctx.restore(); drawSpriteLightReflection(info, item, lightSources, phase); } function spriteLocalPoint(info, worldX, worldY) { if (!info.angle) return { x: worldX - info.drawX, y: worldY - info.drawY }; const pivotX = info.drawX + info.sprite.width / 2; const pivotY = info.drawY + info.sprite.height * 0.8; const dx = worldX - pivotX; const dy = worldY - pivotY; const cos = Math.cos(-info.angle); const sin = Math.sin(-info.angle); return { x: dx * cos - dy * sin + info.sprite.width / 2, y: dx * sin + dy * cos + info.sprite.height * 0.8 }; } function getCursorLightScreenPoint(phase) { if (cursorScreen.active) return { x: cursorScreen.x, y: cursorScreen.y }; return null; } function getLocalSpriteLights(info, phase) { if (visualSettings().enableLights === false || !info?.asset) return []; const lights = areNightLightsActive(phase) ? getAssetLightPointsForSide(info.asset, info.side) : []; const cursorPoint = getCursorLightScreenPoint(phase); if (cursorPoint) { const worldX = (cursorPoint.x - view.x) / view.zoom; const worldY = (cursorPoint.y - view.y) / view.zoom; const local = spriteLocalPoint(info, worldX, worldY); const scale = info.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const lx = local.x / scale - .5; const ly = local.y / scale - .5; const w = assetWidth(info.asset); const h = assetHeight(info.asset); const reach = Math.max(4.5, Math.max(w, h) * 0.86); const margin = reach * 1.35; const nearestX = clamp(lx, 0, Math.max(0, w - 1)); const nearestY = clamp(ly, 0, Math.max(0, h - 1)); const distToBounds = Math.hypot(lx - nearestX, ly - nearestY); const spriteProximity = Math.pow(clamp(1 - distToBounds / Math.max(1, margin), 0, 1), 1.85); if (spriteProximity > 0.01) { lights.push({ x: lx, y: ly, c: '#d6ecff', cursor: true, intensity: 0.3 + spriteProximity * 0.82 }); } } return lights; } function hasCursorInspectionLight(lights) { return Array.isArray(lights) && lights.some((light) => light?.cursor); } function drawWaterLightReflections(lightSources, time, phase) { if (!lightSources?.length || !areNightLightsActive(phase)) return; const rect = getViewportWorldRect(48); const t = time * 0.001; ctx.save(); ctx.imageSmoothingEnabled = false; for (const source of lightSources.slice(0, 80)) { const rgb = parseHex(source.color || '#ffd86a'); if (!rgb) continue; const tilePos = worldToTileFloat(source.x, source.y + 4); const reachTiles = clamp(Math.ceil((source.radius || 18) / 12) + 2, 2, 7); const baseX = Math.round(tilePos.x); const baseY = Math.round(tilePos.y); for (let ty = baseY - reachTiles; ty <= baseY + reachTiles; ty++) { if (ty < 0 || ty >= WORLD_H) continue; for (let tx = baseX - reachTiles; tx <= baseX + reachTiles; tx++) { if (tx < 0 || tx >= WORLD_W) continue; const tile = world.get(tx, ty); if (!tile || tile.type !== 'water') continue; const pos = tileToWorld(tx, ty); const px = pos.x; const py = pos.y + TILE_H * 0.45; if (px + 24 < rect.left || px - 24 > rect.right || py + 18 < rect.top || py - 18 > rect.bottom) continue; const dx = (px - source.x) / Math.max(1, source.radius || 18); const dy = (py - source.y) / Math.max(1, (source.radius || 18) * 0.75); const dist = Math.hypot(dx, dy); const strength = 1 - clamp(dist / 1.85, 0, 1); if (strength <= 0.02) continue; const seed = tx * 17.13 + ty * 31.71 + (source.flickerSeed || 0) * 0.003; const shimmer = 0.72 + pseudoNoise(Math.floor(t * 7) + seed) * 0.38; const alpha = clamp((0.05 + strength * 0.18) * shimmer * (phase.darkness || 1), 0.02, 0.24); ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${alpha})`; const rows = 1 + Math.floor(strength * 3); for (let r = 0; r < rows; r++) { const wobble = Math.round((pseudoNoise(seed + r * 5.9 + Math.floor(t * 4)) - 0.5) * 8); const len = Math.max(2, Math.round((5 + strength * 10) * (r === 0 ? 1 : 0.65))); const yy = Math.round(py + r * 3 + pseudoNoise(seed + r * 2.7) * 2); const xx = Math.round(px + wobble - len / 2); ctx.fillRect(xx, yy, len, 1); if (strength > 0.55 && r === 0) ctx.fillRect(xx + Math.floor(len / 2), yy + 1, 1, 1); } } } } ctx.restore(); } function drawSpriteLightReflection(info, item, lightSources, phase) { if (visualSettings().enableLights === false || !areNightLightsActive(phase) || !lightSources?.length) return; const asset = item.asset; const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const sprite = info.sprite; const sources = []; const selfOwnerId = item.source?.id || asset.id; for (const source of lightSources) { if (source.ownerId && source.ownerId === selfOwnerId) continue; const local = spriteLocalPoint(info, source.x, source.y); const dx = local.x - sprite.width / 2; const dy = local.y - sprite.height / 2; const reach = source.radius * 4.9 + Math.max(sprite.width, sprite.height) * 1.15; const distance = Math.hypot(dx, dy); if (distance <= reach) sources.push({ source, local, distance }); } if (!sources.length) return; sources.sort((a, b) => a.distance - b.distance); const aw = assetWidth(asset); const ah = assetHeight(asset); const pixels = getAssetPixels(asset, info.side || 'right'); const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], aw, ah); const depths = asset.category === 'dynamic' && info.side === 'left' ? mirrorDepthPixels(rawDepths, aw, ah) : rawDepths; const overlay = document.createElement('canvas'); overlay.width = sprite.width; overlay.height = sprite.height; const octx = overlay.getContext('2d'); octx.imageSmoothingEnabled = false; for (let y = 0; y < ah; y++) { for (let x = 0; x < aw; x++) { const pixelCode = pixels[y * aw + x]; if (!pixelCode) continue; const depth = depths[y * aw + x] || 0; if (!depth) continue; const baseRgb = parseHex(colorToHex(pixelCode)); let rr = 0, gg = 0, bb = 0, aa = 0; for (const { source, local } of sources) { if (source.ownerId && source.ownerId === (item.source?.id || item.asset?.id)) continue; const lc = parseHex(source.color || '#ffd86a'); if (!lc) continue; const cellPx = (x + 0.5) * scale; const cellPy = (y + 0.5) * scale; const d = Math.hypot(local.x - cellPx, local.y - cellPy); const reach = Math.max(scale * 2.1, source.radius * 2.35); const t = 1 - clamp(d / reach, 0, 1); if (t <= 0) continue; const edge = getDepthLightFacing(depth, x, y, aw, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5, ah); const depthFactor = depth > 0 ? 0.28 + edge * 0.36 : 0.18 + edge * 0.16; const sourceIntensity = clamp(Number(source.intensity ?? 1) || 1, 0.18, 1.15); const falloff = Math.pow(t, 2.2) * sourceIntensity; const amount = falloff * depthFactor; const br = baseRgb?.r ?? lc.r; const bg = baseRgb?.g ?? lc.g; const bb0 = baseRgb?.b ?? lc.b; rr += (lc.r * 0.82 + br * 0.18) * amount; gg += (lc.g * 0.82 + bg * 0.18) * amount; bb += (lc.b * 0.82 + bb0 * 0.18) * amount; aa += amount; } if (aa <= 0.002) continue; const inv = 1 / aa; const alpha = Math.min(0.13, 0.012 + Math.pow(aa, 1.08) * 0.055); octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${alpha.toFixed(3)})`; octx.fillRect(x * scale, y * scale, scale, scale); if (aa > 0.82 && scale >= 4) { octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${(alpha * 0.18).toFixed(3)})`; octx.fillRect(x * scale + 1, y * scale + 1, Math.max(1, scale - 2), Math.max(1, scale - 2)); } } } ctx.save(); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'screen'; 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) { if (Math.random() > 0.55) return; const baseColor = getTerrainSurfaceColorAt(item.x, item.y); landStepParticles.push({ x: item.x + (Math.random() - 0.5) * 0.16, y: item.y + 0.16 + (Math.random() - 0.5) * 0.05, vx: (Math.random() - 0.5) * 0.04 - (item.vx || 0) * 0.01, vy: -0.014 - Math.random() * 0.018, started: time, life: 240 + Math.random() * 140, color: makeStepParticleColor(baseColor, tile?.type || 'grass'), 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 spawnConfiguredParticleEmitters(items, time) { if (view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles) return; for (const item of items) { const legacyEmitters = item.asset.meta?.particlePixels || []; if (!legacyEmitters.length) continue; const info = getSpriteDrawInfo(item, time, false); const scale = item.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; for (const emitter of legacyEmitters) { if (natureDriftParticles.length >= PHASE5_GUARDRAILS.maxParticles || Math.random() > 0.012) continue; const ex = item.asset.category === 'dynamic' && info.side === 'left' ? assetWidth(item.asset) - 1 - emitter.x : emitter.x; const wx = info.drawX + (ex + 0.5) * scale; const wy = info.drawY + (emitter.y + 0.5) * scale; const velocity = particleVelocityForDirection(emitter.dir || 'up'); natureDriftParticles.push({ x: wx + (Math.random() - 0.5) * 4, y: wy + (Math.random() - 0.5) * 4, vx: velocity.vx + (Math.random() - 0.5) * 4, vy: velocity.vy + (Math.random() - 0.5) * 4, gravity: 4, started: time, life: 1500 + Math.random() * 1600, color: colorToHex(emitter.c || selectedColorCode), sway: Math.random() * Math.PI * 2 }); } } } function particleVelocityForDirection(dir) { const speed = 13; if (dir === 'down') return { vx: 0, vy: speed }; if (dir === 'left') return { vx: -speed, vy: 0 }; if (dir === 'right') return { vx: speed, vy: 0 }; return { vx: 0, vy: -speed }; } function drawNatureDriftParticles(time) { if (!natureDriftParticles.length || view.zoom < PHASE5_GUARDRAILS.particleMinZoom) return; ctx.save(); for (const particle of natureDriftParticles) { const age = (time - particle.started) / particle.life; if (age < 0 || age > 1) continue; const seconds = (time - particle.started) / 1000; const sway = Math.sin(age * 5.5 + particle.sway) * 3.5; const x = particle.x + particle.vx * seconds + sway; const y = particle.y + particle.vy * seconds + age * (particle.gravity ?? 5); ctx.globalAlpha = (1 - age) * 0.72; ctx.fillStyle = particle.color; ctx.fillRect(Math.round(x), Math.round(y), 1, 1); } ctx.restore(); } function drawShipRipples(pos, time, seedValue = '', asset = null) { const seed = fnv1a(String(seedValue)).slice(0, 6); const numericSeed = parseInt(seed, 16) || 1; const cycleMs = 2300; const cycle = Math.floor((time + numericSeed) / cycleMs); if (pseudoNoise(cycle + numericSeed * 0.013) < 0.38) return; const t = ((time + numericSeed) % cycleMs) / cycleMs; const shipScale = clamp((assetMaxSize(asset || {}) || 16) / 16, 1, 4); const waveScale = 0.85 + shipScale * 0.55; const alpha = Math.max(0, 0.34 * (1 - t)); const rx = (5 + t * 22) * waveScale; const ry = (2 + t * 8) * (0.85 + shipScale * 0.34); ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = '#e8fbff'; ctx.lineWidth = Math.max(1, (1.25 + shipScale * 0.18) / view.zoom); const rings = shipScale >= 2.35 ? 2 : 1; for (let i = 0; i < rings; i++) { const grow = i * (7 + shipScale * 3); const fade = i ? 0.58 : 1; ctx.globalAlpha = alpha * fade; ctx.beginPath(); ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx + grow, ry + grow * 0.28, 0, 0, Math.PI * 2); ctx.stroke(); } ctx.restore(); } 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(info, phase) { const { drawX, sprite, pos, anchorY, bob, angle = 0, stretchX = 1, stretchY = 1, asset } = info; const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 }; if ((shadow.alpha || 0) <= 0.001) return; const c = activeShadowCtx || ctx; const silhouette = getShadowCanvas(sprite); const isShip = asset?.subtype === 'ship'; const contactX = drawX + sprite.width / 2; const groundY = pos.y + anchorY + (isShip ? (bob || 0) * 0.82 : 0); const mirrorOffset = isShip ? 0 : Math.max(0, -(bob || 0)); const shadowAngle = isShip ? angle * 0.65 : 0; c.save(); c.globalAlpha = activeShadowCtx ? 1 : shadow.alpha; c.translate(contactX, groundY); if (shadowAngle) c.rotate(shadowAngle); c.transform((1 + shadow.length * 0.08) * stretchX, 0, shadow.skewX, shadow.scaleY * Math.max(0.88, stretchY), 0, 0); c.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset)); c.restore(); if (isShip) { const reflection = getShipReflectionCanvas(sprite); ctx.save(); ctx.globalAlpha = 0.24; ctx.translate(contactX, groundY + 1); if (shadowAngle) ctx.rotate(shadowAngle); ctx.transform(1.08 * stretchX, 0, shadow.skewX * 0.55, 0.36 * Math.max(0.92, stretchY), 0, 0); ctx.drawImage(reflection, Math.round(-sprite.width / 2), 2); 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 getShipReflectionCanvas(sprite) { if (shipReflectionCanvasCache.has(sprite)) return shipReflectionCanvasCache.get(sprite); const canvas = document.createElement('canvas'); canvas.width = sprite.width; canvas.height = sprite.height; const c = canvas.getContext('2d'); c.imageSmoothingEnabled = false; c.save(); c.translate(0, sprite.height); c.scale(1, -1); c.drawImage(sprite, 0, 0); c.restore(); c.globalCompositeOperation = 'source-in'; const refl = c.createLinearGradient(0, 0, 0, canvas.height); refl.addColorStop(0, 'rgba(174,230,255,0.46)'); refl.addColorStop(0.55, 'rgba(92,172,214,0.20)'); refl.addColorStop(1, 'rgba(54,120,170,0.02)'); c.fillStyle = refl; c.fillRect(0, 0, canvas.width, canvas.height); shipReflectionCanvasCache.set(sprite, canvas); return canvas; } function currentVoterKey() { return String(state.account?.id || state.authorName || els.authorName?.value || 'Local Artist').trim().toLowerCase(); } 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 findRemixSourceObject(asset) { const ids = [asset?.parentAssetId, asset?.originalAssetId].filter(Boolean); for (const assetId of ids) { const placed = (state.placed || []).find((p) => p.assetId === assetId && !state.hiddenObjects?.[p.id]); if (placed) return { kind: 'static', object: placed, asset: findAsset(assetId), x: placed.x + .5, y: placed.y + .5 }; const summon = (state.dynamicSummons || []).find((p) => p.assetId === assetId && !state.hiddenObjects?.[p.id]); if (summon) return { kind: 'dynamic', object: summon, asset: findAsset(assetId), x: summon.homeX + .5, y: summon.homeY + .5 }; } return null; } function centerViewOnWorldCoord(x, y, zoom = null) { const pos = tileToWorld(x, y); pos.y -= getLiftAtCoord(x, y); if (zoom != null) view.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM); view.x = cw / 2 - pos.x * view.zoom; view.y = ch / 2 - (pos.y - 32) * view.zoom; render(performance.now()); } function teleportToRemixSource() { if (!selectedObject) return; const asset = findAsset(selectedObject.assetId); const source = findRemixSourceObject(asset); if (!source || !source.object || !source.asset) { toast('Remix source is not placed on the island right now.'); return; } selectWorldObject(source.kind, source.object.id, source.asset.id, performance.now()); centerViewOnWorldCoord(source.x, source.y, Math.max(view.zoom, 1.05)); toast(`Teleported to ${source.asset.name || 'remix source'}.`); } function toggleBubbleMenu() { if (!els.bubbleMenuActions) return; els.bubbleMenuActions.hidden = !els.bubbleMenuActions.hidden; } function getSelectedDrawableItem(time = performance.now()) { if (!selectedObject) return null; const items = getDrawableItems(time, getViewportWorldRect(512)); return items.find((item) => item.source?.id === selectedObject.id && item.asset?.id === selectedObject.assetId) || null; } function getSelectedBubbleAnchor(time = performance.now()) { if (!selectedObject) return null; const item = getSelectedDrawableItem(time); if (item) { const info = getSpriteDrawInfo(item, time, false); const topPad = Math.max(6, Math.min(14, info.sprite.height * 0.1)); const baseY = info.pos.y + info.anchorY - info.sprite.height; return { x: info.drawX + info.sprite.width / 2, y: baseY - topPad, asset: item.asset, objectId: item.source?.id || selectedObject.id }; } const cached = lastRenderedSpriteInfo.get(selectedObject.id); if (!cached) return null; const topPad = Math.max(6, Math.min(14, cached.sprite.height * 0.1)); const baseY = cached.pos && Number.isFinite(cached.anchorY) ? cached.pos.y + cached.anchorY - cached.sprite.height : cached.drawY; return { x: cached.drawX + cached.sprite.width / 2, y: baseY - topPad, asset: cached.asset, objectId: selectedObject.id }; } function updateSelectionBubble(time) { if (!els.selectionBubble) return; const selected = getSelectedWorldPosition(); if (!selected) { els.selectionBubble.hidden = true; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } const anchor = getSelectedBubbleAnchor(time) || { x: selected.pos.x, y: selected.pos.y - Math.max(48, assetMaxSize(selected.asset) * 2), asset: selected.asset, objectId: selected.objectId }; const { asset, objectId } = anchor; const rawSx = anchor.x * view.zoom + view.x; const rawSy = anchor.y * view.zoom + view.y; const fixedFollowBubble = cameraFollowSelected && selectedObject; const sx = fixedFollowBubble ? cw * 0.5 : rawSx; const sy = fixedFollowBubble ? Math.max(92, ch * 0.32) : rawSy; if (!fixedFollowBubble && (sx < -120 || sy < -160 || sx > cw + 120 || sy > ch + 120)) { els.selectionBubble.hidden = true; if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true; return; } els.bubbleName.textContent = asset.name || 'Untitled'; els.bubbleAuthor.textContent = `by ${displayAssetAuthor(asset)}`; const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null; const remixSource = findRemixSourceObject(asset); if (els.bubbleRemixFrom) { els.bubbleRemixFrom.hidden = !parent; els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : ''; } if (els.bubbleTeleport) { els.bubbleTeleport.hidden = !remixSource; els.bubbleTeleport.textContent = remixSource?.asset ? `Teleport to ${remixSource.asset.name || 'source'}` : 'Teleport to source'; } if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`; const votes = getObjectVoteCounts(objectId); const voter = currentVoterKey(); 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.bubbleEdit) { els.bubbleEdit.hidden = !isCurrentUserAsset(asset); } if (els.bubbleReport) { const reported = hasReportForObject(objectId); els.bubbleReport.disabled = reported; els.bubbleReport.textContent = reported ? 'Reported' : 'Report'; } els.selectionBubble.hidden = false; els.selectionBubble.classList.remove('belowSprite'); const rect = els.selectionBubble.getBoundingClientRect(); const halfW = Math.max(70, (rect.width || 180) / 2); const height = Math.max(64, rect.height || 120); const bubbleX = clamp(sx, halfW + 8, Math.max(halfW + 8, cw - halfW - 8)); const aboveY = sy; const belowY = fixedFollowBubble ? sy : (selected.pos.y * view.zoom + view.y) + Math.max(36, assetMaxSize(asset) * view.zoom * 0.5); const useBelow = !fixedFollowBubble && aboveY - height < 8; const bubbleY = fixedFollowBubble ? clamp(sy, height + 8, Math.max(height + 8, ch - 8)) : useBelow ? clamp(belowY, 8, Math.max(8, ch - height - 8)) : clamp(aboveY, height + 8, Math.max(height + 8, ch - 8)); els.selectionBubble.classList.toggle('belowSprite', useBelow); els.selectionBubble.style.transform = useBelow ? 'translate(-50%, 0)' : 'translate(-50%, -100%)'; els.selectionBubble.style.setProperty('--bubble-left', `${Math.round(bubbleX)}px`); els.selectionBubble.style.setProperty('--bubble-top', `${Math.round(bubbleY)}px`); } function voteSelected(delta) { 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(); updateRotationStats(); // 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) remixEdit(asset); } function editSelectedOriginal() { if (!selectedObject) return; const asset = findAsset(selectedObject.assetId); if (!asset) return; const mine = isCurrentUserAsset(asset); if (!mine) { toast('Only your own assets can be edited directly. Use Remix instead.'); return; } editOriginalAsset(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; } let pendingConfirmResolve = null; function confirmWebsiteDialog({ title = 'Confirm', message = 'Are you sure?', okText = 'OK', danger = false } = {}) { if (!els.confirmDialog || !els.confirmOk || !els.confirmCancel) return Promise.resolve(false); if (els.confirmTitle) els.confirmTitle.textContent = title; if (els.confirmMessage) els.confirmMessage.textContent = message; els.confirmOk.textContent = okText; els.confirmOk.classList.toggle('danger', danger); els.confirmDialog.hidden = false; els.confirmOk.focus?.({ preventScroll: true }); return new Promise((resolve) => { pendingConfirmResolve = resolve; }); } function resolveConfirmDialog(confirmed) { if (els.confirmDialog) els.confirmDialog.hidden = true; const resolve = pendingConfirmResolve; pendingConfirmResolve = null; resolve?.(Boolean(confirmed)); } 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 - assetHeight(selected.asset) * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 25, color: palette[i % palette.length], 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, time = performance.now()) { ctx.save(); for (const source of sources) { const sx = source.x * view.zoom + view.x; const sy = source.y * view.zoom + view.y; const flicker = 0.94 + pseudoNoise((time * 0.0017) + (source.flickerSeed || 0)) * 0.12; const radius = source.radius * view.zoom * flicker; const intensity = 0.68 + (flicker - 0.94) * 0.28; const auraRadius = radius * 1.12; if (sx < -auraRadius || sy < -auraRadius || sx > cw + auraRadius || sy > ch + auraRadius) continue; // Light cores are additive so they brighten art like the cursor glow instead of // laying a flat translucent disk over the sprite. ctx.globalCompositeOperation = 'lighter'; const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48)); core.addColorStop(0, hexToRgba(source.color, .36 * intensity)); core.addColorStop(.28, hexToRgba(source.color, .18 * intensity)); 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, .13 * intensity)); glow.addColorStop(.42, hexToRgba(source.color, .05 * intensity)); 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.globalCompositeOperation = 'source-over'; ctx.fillStyle = hexToRgba(source.color, .92); const coreSize = Math.max(2, Math.round(2.6 * view.zoom)); ctx.fillRect(Math.round(sx - coreSize / 2), Math.round(sy - coreSize / 2), coreSize, coreSize); ctx.fillStyle = 'rgba(255, 250, 206, .54)'; const hotSize = Math.max(1, Math.round(1.2 * view.zoom)); ctx.fillRect(Math.round(sx - hotSize / 2), Math.round(sy - hotSize / 2), hotSize, hotSize); } ctx.restore(); } function getSpriteCanvas(asset, side, localLights = null) { const lightsOn = areNightLightsActive(renderPhase); const dynamicLights = Array.isArray(localLights) && localLights.length > 0; const lightSignature = dynamicLights ? localLights.map((l) => `${Math.round(l.x * 2) / 2},${Math.round(l.y * 2) / 2},${l.c || ''},${l.cursor ? 'cursor' : 'self'}`).join('|') : ''; const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}:${visualSettings().enableLights === false ? 'depthoff' : 'depthon'}:${lightSignature}` : `day:unlit:${lightSignature}`; const w = assetWidth(asset); const h = assetHeight(asset); const revision = asset.contentHash || asset.updatedAt || asset.createdAt || asset.pixels || asset.faces?.right || ''; const key = `${asset.id}:${revision}:${side}:${w}x${h}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`; const skipCache = dynamicLights && localLights.some((l) => l.cursor); if (!skipCache && spriteCache.has(key)) { const cached = spriteCache.get(key); spriteCache.delete(key); spriteCache.set(key, cached); return cached; } const pixels = getAssetPixels(asset, side); const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], w, h); const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, w, h) : rawDepths; const lights = dynamicLights ? localLights : (lightsOn ? getAssetLightPointsForSide(asset, side) : []); const depthVisualsOn = visualSettings().enableLights !== false; const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE; const canvas = document.createElement('canvas'); canvas.width = w * scale; canvas.height = h * scale; const c = canvas.getContext('2d'); c.imageSmoothingEnabled = false; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const color = pixels[y * w + x]; if (!color) continue; const depth = depthVisualsOn ? (depths[y * w + x] || 0) : 0; c.fillStyle = shadeAssetPixelColor(colorToHex(color), depth, x, y, w, renderPhase, pixels, depthVisualsOn ? depths : null, lights, h); c.fillRect(x * scale, y * scale, scale, scale); } } if (!skipCache) rememberSpriteCanvas(key, canvas); return canvas; } function rememberSpriteCanvas(key, canvas) { spriteCache.set(key, canvas); while (spriteCache.size > MAX_SPRITE_CACHE_ENTRIES) { const oldest = spriteCache.keys().next().value; spriteCache.delete(oldest); } } function getAssetPixels(asset, side = 'right') { const w = assetWidth(asset); const h = assetHeight(asset); const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(w, h), w, h); if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, w, h); return right; } 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); const orderedTiles = tiles.slice().sort(terrainDrawCompare); for (const tile of orderedTiles) { drawTerrainTile(c, tile, worldData); if (tile.type === 'highland') drawHighlandWallFaces(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 }; chunks.sort((a, b) => (a.tileX + a.tileY) - (b.tileX + b.tileY) || a.tileY - b.tileY || a.tileX - b.tileX); return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize, coastTiles }; } 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 terrainDrawCompare(a, b) { return (a.x + a.y) - (b.x + b.y) || a.y - b.y || a.x - b.x; } function getTerrainChunkBounds(tiles) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const tile of tiles) { 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 drawHighlandForegroundFaces(worldData, items = []) { const rect = getViewportWorldRect(64); const tiles = []; 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 !== 'highland') continue; const p = tileToWorld(x, y); if (p.x + TILE_W < rect.left || p.x - TILE_W > rect.right || p.y + TILE_H < rect.top || p.y - 40 > rect.bottom) continue; const frontLeft = worldData.get(x, y + 1); const frontRight = worldData.get(x + 1, y); if (!frontLeft || frontLeft.type !== 'highland' || !frontRight || frontRight.type !== 'highland') tiles.push(tile); } } tiles.sort(terrainDrawCompare); for (const tile of tiles) drawHighlandWallFaces(ctx, tile, worldData, true); } 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(); } return canvas; } function drawCoastalFoam(time) { return; /* disabled by request */ if (!terrainCache?.coastTiles?.length || !coastalFoamTextures) return; const rect = getViewportWorldRect(64); const t = time * 0.001; for (const tile of terrainCache.coastTiles) { const pos = tileToWorld(tile.x, tile.y); if (pos.x + 28 < rect.left || pos.x - 28 > rect.right || pos.y + 24 < rect.top || pos.y - 24 > rect.bottom) continue; const seed = (tile.x * 17.17 + tile.y * 7.91); const drift = Math.sin(t * 2.4 + seed) * 5.0; const bob = Math.cos(t * 2.9 + seed * .7) * 2.1; const crawl = Math.sin(t * 1.25 + seed * .41) * 3.4; const pulse = 0.68 + Math.sin(t * 3.1 + seed * .31) * 0.32; const baseY = pos.y + TILE_H * 0.42; ctx.save(); ctx.globalAlpha = 0.20 + 0.18 * pulse; ctx.translate(pos.x + drift, baseY + bob + crawl * .25); ctx.rotate(time / 9500 + (tile.x + tile.y) * 0.035); ctx.scale(0.92 + pulse * 0.12, 0.86 + pulse * 0.10); ctx.drawImage(coastalFoamTextures.a, -12, -12); ctx.restore(); ctx.save(); ctx.globalAlpha = 0.14 + 0.12 * (1 - pulse); ctx.translate(pos.x - drift * 0.65 + crawl * .35, baseY + 1 - bob * 0.7); ctx.rotate(-(time / 12500) + (tile.x - tile.y) * 0.03); ctx.scale(0.78 + (1 - pulse) * 0.18, 0.78 + pulse * 0.12); ctx.drawImage(coastalFoamTextures.b, -12, -12); ctx.restore(); } } 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); 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') { drawTerrainAmbientOcclusion(c, tile, worldData, x, y, lift); } 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 drawTerrainAmbientOcclusion(c, tile, worldData, x, y, lift) { // Terrain AO linework removed: it produced horizontal artifacts on highland tiles. } function drawHighlandWallFaces(c, tile, worldData, foreground = false) { for (const face of getHighlandWallFaceEntries(tile, worldData)) drawHighlandWallFace(c, tile, worldData, face.side); } function drawHighlandWallFace(c, tile, worldData, side) { const { x, y } = tileToWorld(tile.x, tile.y); const lift = getTileLift(tile); if (side === 'right') { c.fillStyle = '#7f8b66'; c.beginPath(); c.moveTo(x, y + TILE_H - lift); c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift); c.lineTo(x + TILE_W / 2, y + TILE_H / 2); c.lineTo(x, y + TILE_H); c.closePath(); c.fill(); } else if (side === 'left') { c.fillStyle = '#6f7d5d'; c.beginPath(); c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift); c.lineTo(x, y + TILE_H - lift); c.lineTo(x, y + TILE_H); c.lineTo(x - TILE_W / 2, y + TILE_H / 2); c.closePath(); c.fill(); } } function getTileLift(tile) { return tile?.type === 'highland' ? 12 : 0; } 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 makeStepParticleColor(baseColor, tileType) { const presets = { grass: ['#d7f39a', '#b7e36f', '#f0cf61', '#6fbf65'], sand: ['#fff1ba', '#f2c879', '#ffffff', '#d4a957'], highland: ['#d5e8a4', '#b4c77c', '#ffffff', '#7f9e6d'], water: ['#d9f7ff', '#a8e5ff'] }; const list = presets[tileType] || presets.grass; const chosen = Math.random() < 0.72 ? list[Math.floor(Math.random() * list.length)] : varyHexColor(baseColor, 58, 34, 28); return chosen; } function loadState() { try { for (const key of LEGACY_STORAGE_KEYS) localStorage.removeItem(key); 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)) { const loadedSchema = Number(expanded.schema || 0); // v16 reset local art because the palette code mapping and seed artwork were intentionally rebuilt. // v17 expanded the default gallery. v18 added more nature-themed defaults. v19 added giant showcase objects. v20 added more animals, fish, and birds. v21 added human and artificial-object defaults. v22 widened small-sprite hitboxes and fixed night cursor depth light. v23 strengthens multi-light depth response and upgrades lower-quality seed art while preserving v16+ user state. v24 sharpens near-light depth reflection falloff and expands the outlined default gallery. v25 fixes gray local-light wash, brings the selection bubble closer to sprite tops, and adds large heritage/masterpiece default works. v26 removes the single-light gray bloom regression, smooths cursor-light falloff, and removes the unwanted blue cursor halo artifact. v27 reduces gray bloom from asset-mounted lights, fixes the clock-hand offset, temporarily enables delete-for-all works, and expands the default gallery again. if (loadedSchema < 16) return seedState(); return normalizeState(expanded); } } } catch (error) { console.warn('Could not load local state.', error); } return seedState(); } function saveState() { state.schema = SAVE_SCHEMA; ensureWorldProtectionState(); canonicalizeAssetStorage(); if (state.worldMode !== 'shared') adoptLocalCollectionOwnership(state); 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(); updateRotationStats(); } function dedupeNormalizedAssets(assets) { const byId = new Map(); const exact = new Set(); const out = []; for (const asset of assets || []) { if (!asset?.id) continue; if (byId.has(asset.id)) continue; const key = [asset.contentHash || '', asset.name || '', asset.category || '', asset.subtype || '', asset.width || asset.size || '', asset.height || asset.size || '', normalizeOwnerAccountId(asset.ownerAccountId)].join('|'); if (asset.contentHash && exact.has(key)) continue; byId.set(asset.id, asset); if (asset.contentHash) exact.add(key); out.push(asset); } return out; } function assetUnsupportedPaletteCodes(asset) { const unsupported = new Set(); const scanEncodedPixels = (value) => { if (typeof value !== 'string' || value === 'mirror') return; for (const ch of value) { if (ch !== '.' && !PALETTE_BY_CODE[ch]) unsupported.add(ch); } }; scanEncodedPixels(asset?.pixels); if (asset?.faces && typeof asset.faces === 'object') { scanEncodedPixels(asset.faces.right); scanEncodedPixels(asset.faces.left); } const meta = asset?.meta || {}; for (const point of meta.lightPixels || []) { if (typeof point?.c === 'string' && point.c.length === 1 && !PALETTE_BY_CODE[point.c]) unsupported.add(point.c); } for (const point of meta.particlePixels || []) { if (typeof point?.c === 'string' && point.c.length === 1 && !PALETTE_BY_CODE[point.c]) unsupported.add(point.c); } return unsupported; } function removeUnrepresentableAssets(input) { if (!input || !Array.isArray(input.assets)) return input; const removedIds = new Set(); const removedNames = []; const assets = input.assets.filter((asset) => { const unsupported = assetUnsupportedPaletteCodes(asset); if (!unsupported.size) return true; if (asset?.id) removedIds.add(asset.id); removedNames.push(`${asset?.name || 'Untitled'} [${[...unsupported].join(' ')}]`); return false; }); if (!removedIds.size) return input; console.warn(`Removed ${removedIds.size} collection work(s) with palette codes unavailable in the current palette: ${removedNames.join(', ')}`); const next = { ...input, assets }; next.placed = Array.isArray(input.placed) ? input.placed.filter((item) => !removedIds.has(item?.assetId)) : input.placed; next.dynamicSummons = Array.isArray(input.dynamicSummons) ? input.dynamicSummons.filter((item) => !removedIds.has(item?.assetId)) : input.dynamicSummons; next.assetVotes = Object.fromEntries(Object.entries(input.assetVotes || {}).filter(([id]) => !removedIds.has(id))); next.hiddenAssets = Object.fromEntries(Object.entries(input.hiddenAssets || {}).filter(([id]) => !removedIds.has(id))); return next; } function normalizeState(input) { input = removeUnrepresentableAssets(input); const normalized = { schema: SAVE_SCHEMA, authorName: input.authorName || 'Local Artist', assets: dedupeNormalizedAssets(Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : []), placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []), dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []), objectVotes: input.objectVotes || {}, 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 || {}) }, account: normalizeAccount(input.account), publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : [], worldMode: input.worldMode === 'shared' ? 'shared' : 'local', serverSync: input.serverSync || { lastServerEventId: null, pendingCommands: [] }, tombstones: input.tombstones || { assets: {}, objects: {} }, deletedSeedAssetNames: Array.isArray(input.deletedSeedAssetNames) ? input.deletedSeedAssetNames : [] }; ensureWorldProtectionState(normalized); for (const tombstone of Object.values(normalized.tombstones.assets || {})) { if (tombstone?.name && !normalized.deletedSeedAssetNames.includes(tombstone.name)) normalized.deletedSeedAssetNames.push(tombstone.name); } const ownerByAssetId = new Map(normalized.assets.map((asset) => [asset.id, normalizeOwnerAccountId(asset.ownerAccountId)])); normalized.placed.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); }); normalized.dynamicSummons.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); }); normalized.assets = normalized.assets.filter((asset) => !normalized.tombstones.assets?.[asset.id]); normalized.placed = normalized.placed.filter((item) => !normalized.tombstones.objects?.[item.id]); normalized.dynamicSummons = normalized.dynamicSummons.filter((item) => !normalized.tombstones.objects?.[item.id]); normalized.placed = repairStaticPlacementTerrain(normalized.placed, normalized.assets); if (normalized.worldMode !== 'shared') adoptLocalCollectionOwnership(normalized); return normalized; } function adoptLocalCollectionOwnership(target) { const author = target.authorName || 'Local Artist'; target.assets = (target.assets || []).map((asset) => ({ ...asset, author, ownerAccountId: '' })); target.placed = (target.placed || []).map((item) => ({ ...item, ownerAccountId: '' })); target.dynamicSummons = (target.dynamicSummons || []).map((item) => ({ ...item, ownerAccountId: '' })); return target; } function canonicalizeAssetStorage() { let changed = false; state.assets = (state.assets || []).map((asset) => { const right = asset?.faces?.right || asset?.pixels; const needsCanonical = Array.isArray(right) || Array.isArray(asset?.pixels) || Array.isArray(asset?.meta?.depthPixels); if (!needsCanonical) return asset; changed = true; return normalizeAsset(asset); }); if (changed) rebuildWorldIndex(); } function normalizeAsset(asset) { const width = assetWidth(asset); const height = assetHeight(asset); const size = Math.max(width, height); const category = asset.category === 'dynamic' ? 'dynamic' : 'static'; const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(width, height), width, height); const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other'); const normalized = { id: asset.id || uid(), name: asset.name || 'Untitled', category, subtype, size, width, height, pixels: encodePixels(right, width, height), faces: category === 'dynamic' ? { right: encodePixels(right, width, height), left: 'mirror' } : null, parentAssetId: asset.parentAssetId || null, originalAssetId: asset.originalAssetId || null, createdAt: asset.createdAt || Date.now(), updatedAt: asset.updatedAt || asset.createdAt || Date.now(), author: asset.author || 'Local Artist', ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, asset.accountId || asset.authorId || ''), version: Number(asset.version) || 1, meta: normalizeAssetMeta(asset.meta || {}, category, subtype, width, height, right) }; normalized.contentHash = computeAssetContentHash(normalized); return normalized; } function normalizeAssetMeta(meta, category, subtype, width, height = width, sourcePixels = null) { const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], width, height); const lightPixels = Array.isArray(meta.lightPixels) ? meta.lightPixels .map((p) => ({ x: clampInt(p.x, 0, width - 1, 0), y: clampInt(p.y, 0, height - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; const legacyParticlePixels = Array.isArray(meta.particlePixels) ? meta.particlePixels .map((p) => ({ x: clampInt(p.x, 0, width - 1, 0), y: clampInt(p.y, 0, height - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : []; const normalized = buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, width, legacyParticlePixels, sourcePixels, height); if (meta.galleryOrigin) normalized.galleryOrigin = String(meta.galleryOrigin); return normalized; } function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, width = 8, sourceParticle = null, sourcePixels = null, height = width) { const w = clampDimension(width, 8); const h = clampDimension(height, w); const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], w, h); const hasDepth = normalizedDepth.some(Boolean); const pixelSource = sourcePixels ? normalizePixels(sourcePixels, w, h) : null; const lightPixelsClean = sanitizeLightPixels(sourceLightPixels, pixelSource || Array(w * h).fill('x'), w, w, h, lightColor || '#ffd86a', false) .map((p) => ({ x: p.x, y: p.y, c: PALETTE_BY_CODE[p.c] ? p.c : nearestPaletteCode(lightColor || '#ffd86a') })); const legacyParticlePixels = Array.isArray(sourceParticle) ? sourceParticle .map((p) => ({ x: clampInt(p.x, 0, w - 1, 0), y: clampInt(p.y, 0, h - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!pixelSource || pixelSource[p.y * w + p.x])) : []; const particleSource = !Array.isArray(sourceParticle) && sourceParticle ? sourceParticle : (legacyParticlePixels.length ? { enabled: true, c: legacyParticlePixels[0].c, dir: legacyParticlePixels[0].dir || 'up' } : null); const particleClean = normalizeParticleConfig(particleSource || { enabled: false, c: nearestPaletteCode('#ffffff'), dir: 'up' }, Math.max(w, h)); const particleCells = legacyParticlePixels.map((p) => ({ x: p.x, y: p.y, c: PALETTE_BY_CODE[p.c] ? p.c : nearestPaletteCode('#ffffff'), dir: ['up','down','left','right'].includes(p.dir) ? p.dir : (particleClean.dir || 'up') })); const hasLight = lightPixelsClean.length > 0; const hasParticles = particleCells.length > 0; return { hasLight, lightPixels: hasLight ? lightPixelsClean : [], lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null, hasParticles, particlePixels: hasParticles ? particleCells : [], particleConfig: { enabled: hasParticles, c: particleCells[0]?.c || particleClean.c, dir: particleCells[0]?.dir || particleClean.dir }, depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null, door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, w - 1, Math.floor(w / 2)), y: clampInt(door.y, 0, h - 1, h - 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(), publishedAt: item.publishedAt || item.placedAt || item.createdAt || Date.now(), status: item.status || 'active', ownerAccountId: normalizeOwnerAccountId(item.ownerAccountId), version: Number(item.version) || 1 })).filter((item) => item.assetId); } function repairStaticPlacementTerrain(placements, assets) { const assetById = new Map((assets || []).map((asset) => [asset.id, asset])); return (placements || []).map((item) => { const asset = assetById.get(item.assetId); if (!asset || asset.category !== 'static') return item; const tile = world.get(item.x, item.y); const wantsWater = asset.subtype === 'water' || asset.subtype === 'ship'; const valid = tile && (wantsWater ? tile.type === 'water' : tile.type !== 'water'); if (valid) return item; const target = wantsWater ? findNearestTerrain('water', item.x, item.y) : findNearestLandTerrain(item.x, item.y); return { ...item, x: target.x, y: target.y }; }); } 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(), publishedAt: item.publishedAt || item.createdAt || item.placedAt || Date.now(), status: item.status || 'active', ownerAccountId: normalizeOwnerAccountId(item.ownerAccountId), version: Number(item.version) || 1, serverState: item.serverState && typeof item.serverState === 'object' ? { ...item.serverState } : null })).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 findNearestTerrainByPredicate(startX, startY, predicate) { 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 || !predicate(tile, x, y)) 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 findNearestLandTerrain(startX, startY) { return findNearestTerrainByPredicate(startX, startY, (tile) => tile.type !== 'water'); } function homeFromPos(pos) { return { homeX: pos.x, homeY: pos.y }; } function buildDefaultGalleryPack(now = Date.now()) { const assets = [ makeAsset('Crescent Tea House', 'static', 'building', drawCrescentTeaHouse(), { depthPixels: drawCrescentTeaHouseDepth(), lightPixels: [{ x: 6, y: 5 }, { x: 9, y: 5 }, { x: 7, y: 10 }, { x: 8, y: 10 }], lightColor: '#ffe55d', door: { x: 7, y: 14 } }), makeAsset('Prism Sakura', 'static', 'nature', drawPrismSakura(), { depthPixels: drawPrismSakuraDepth(), particlePixels: [{ x: 5, y: 4, c: nearestPaletteCode('#ffb0c7'), dir: 'up' }, { x: 10, y: 5, c: nearestPaletteCode('#ead0ff'), dir: 'up' }] }), makeAsset('Clockwork Whale', 'static', 'ship', drawClockworkWhale(), { depthPixels: drawClockworkWhaleDepth() }), makeAsset('Lantern Cat', 'dynamic', 'animal', drawLanternCatRight(), { lightPixels: [{ x: 11, y: 8 }], lightColor: '#ffe55d', depthPixels: drawSmallRaisedDepth() }), makeAsset('Cloud Koi', 'dynamic', 'fish', drawCloudKoiRight(), { particlePixels: [{ x: 5, y: 8, c: nearestPaletteCode('#aef8ff'), dir: 'up' }] }), makeAsset('Paper Crane', 'dynamic', 'bird', drawPaperCraneRight(), { particlePixels: [{ x: 8, y: 6, c: nearestPaletteCode('#eef4ff'), dir: 'up' }] }), makeAsset('Moon Lantern', 'static', 'building', drawMoonLantern(), { depthPixels: drawMoonLanternDepth(), lightPixels: [{ x: 6, y: 7 }, { x: 7, y: 7 }, { x: 8, y: 7 }, { x: 7, y: 8 }, { x: 8, y: 8 }], lightColor: '#ffe99a', door: { x: 7, y: 14 } }), makeAsset('Glass Fern', 'static', 'nature', drawGlassFern(), { depthPixels: filledDepthFromPixels(drawGlassFern()) }), makeAsset('Linen Cottage', 'static', 'building', drawCottage(), { depthPixels: filledDepthFromPixels(drawCottage()), lightPixels: [{ x: 10, y: 9 }], lightColor: '#ffe38a', door: { x: 7, y: 13 } }), makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster(), { depthPixels: filledDepthFromPixels(drawPineCluster()) }), makeAsset('Shell Rock', 'static', 'nature', drawShellRock(), { depthPixels: filledDepthFromPixels(drawShellRock()) }), makeAsset('Coral Skiff', 'static', 'ship', drawCoralSkiff(), { depthPixels: drawCoralSkiffDepth() }), makeAsset('Sprout Fox', 'dynamic', 'animal', drawSproutFoxRight(), { depthPixels: drawSmallRaisedDepth() }), makeAsset('Azure Minnow', 'dynamic', 'fish', drawAzureMinnowRight(), { particlePixels: [{ x: 5, y: 7, c: nearestPaletteCode('#bff7ff'), dir: 'up' }] }), makeAsset('Violet Moth', 'dynamic', 'animal', drawVioletMothRight(), { particlePixels: [{ x: 7, y: 3, c: nearestPaletteCode('#ead1ff'), dir: 'up' }] }), makeAsset('Lantern Walker', 'dynamic', 'human', drawLanternWalkerRight(), { lightPixels: [{ x: 9, y: 7 }, { x: 11, y: 8 }], lightColor: '#ffe99a', depthPixels: drawSmallRaisedDepth() }), makeAssetSized('Wildflower Patch', 'static', 'nature', drawWildflowerPatch(), 8, 8, { depthPixels: filledDepthFromPixelsSized(drawWildflowerPatch(), 8, 8) }), makeAssetSized('River Stones', 'static', 'nature', drawRiverStones(), 8, 8, { depthPixels: filledDepthFromPixelsSized(drawRiverStones(), 8, 8) }), makeAsset('Maple Canopy', 'static', 'nature', drawMapleCanopy(), { depthPixels: drawMapleCanopyDepth() }), makeAsset('Misty Falls', 'static', 'nature', drawMistyFalls(), { depthPixels: drawMistyFallsDepth(), particlePixels: [{ x: 6, y: 8, c: nearestPaletteCode('#dffbff'), dir: 'up' }, { x: 10, y: 8, c: nearestPaletteCode('#dffbff'), dir: 'up' }] }), makeAssetSized('Lotus Pond', 'static', 'nature', drawLotusPond(), 16, 12, { depthPixels: drawLotusPondDepth(), particlePixels: [{ x: 6, y: 6, c: nearestPaletteCode('#ffd9e8'), dir: 'up' }, { x: 11, y: 5, c: nearestPaletteCode('#ffffff'), dir: 'up' }] }), makeAsset('Mossy Arch', 'static', 'nature', drawMossyArch(), { depthPixels: drawMossyArchDepth() }), makeAssetSized('Sunflower Grove', 'static', 'nature', drawSunflowerGrove(), 12, 16, { depthPixels: filledDepthFromPixelsSized(drawSunflowerGrove(), 12, 16) }), makeAssetSized('Reed Bed', 'static', 'nature', drawReedBed(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawReedBed(), 12, 12) }), makeAssetSized('Firefly Swirl', 'dynamic', 'animal', drawFireflySwirlRight(), 8, 8, { lightPixels: [{ x: 3, y: 4 }, { x: 5, y: 3 }], lightColor: '#fff08a', depthPixels: filledDepthFromPixelsSized(drawFireflySwirlRight(), 8, 8) }), makeAssetSized('Meadow Hare', 'dynamic', 'animal', drawMeadowHareRight(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawMeadowHareRight(), 12, 12) }), makeAssetSized('Brook Turtle', 'dynamic', 'animal', drawBrookTurtleRight(), 12, 10, { depthPixels: filledDepthFromPixelsSized(drawBrookTurtleRight(), 12, 10), particlePixels: [{ x: 1, y: 7, c: nearestPaletteCode('#a8e5ff'), dir: 'up' }] }), makeAssetSized('Leaf Sparrow', 'dynamic', 'bird', drawLeafSparrowRight(), 8, 8, { depthPixels: filledDepthFromPixelsSized(drawLeafSparrowRight(), 8, 8) }), makeAssetSized('Red Panda', 'dynamic', 'animal', drawRedPandaRight(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawRedPandaRight(), 12, 12) }), makeAssetSized('River Otter', 'dynamic', 'animal', drawRiverOtterRight(), 14, 8, { depthPixels: filledDepthFromPixelsSized(drawRiverOtterRight(), 14, 8) }), makeAssetSized('Amber Deer', 'dynamic', 'animal', drawAmberDeerRight(), 14, 14, { depthPixels: filledDepthFromPixelsSized(drawAmberDeerRight(), 14, 14) }), makeAssetSized('Forest Owl', 'dynamic', 'bird', drawForestOwlRight(), 10, 12, { depthPixels: filledDepthFromPixelsSized(drawForestOwlRight(), 10, 12) }), makeAssetSized('Kingfisher', 'dynamic', 'bird', drawKingfisherRight(), 10, 8, { depthPixels: filledDepthFromPixelsSized(drawKingfisherRight(), 10, 8) }), makeAssetSized('Pond Duck', 'dynamic', 'bird', drawPondDuckRight(), 12, 10, { depthPixels: filledDepthFromPixelsSized(drawPondDuckRight(), 12, 10) }), makeAssetSized('Heron', 'dynamic', 'bird', drawHeronRight(), 10, 16, { depthPixels: filledDepthFromPixelsSized(drawHeronRight(), 10, 16) }), makeAssetSized('Sunset Koi', 'dynamic', 'fish', drawSunsetKoiRight(), 12, 8, { depthPixels: filledDepthFromPixelsSized(drawSunsetKoiRight(), 12, 8), particlePixels: [{ x: 2, y: 4, c: nearestPaletteCode('#c8f4ff'), dir: 'up' }] }), makeAssetSized('Silver Trout', 'dynamic', 'fish', drawSilverTroutRight(), 12, 6, { depthPixels: filledDepthFromPixelsSized(drawSilverTroutRight(), 12, 6), particlePixels: [{ x: 1, y: 3, c: nearestPaletteCode('#dffbff'), dir: 'up' }] }), makeAssetSized('Butterfly Fish', 'dynamic', 'fish', drawButterflyFishRight(), 10, 8, { depthPixels: filledDepthFromPixelsSized(drawButterflyFishRight(), 10, 8), particlePixels: [{ x: 1, y: 4, c: nearestPaletteCode('#bff7ff'), dir: 'up' }] }), makeAssetSized('Town Gardener', 'dynamic', 'human', drawTownGardenerRight(), 12, 16, { depthPixels: filledDepthFromPixelsSized(drawTownGardenerRight(), 12, 16) }), makeAssetSized('Lantern Courier', 'dynamic', 'human', drawLanternCourierRight(), 12, 16, { depthPixels: filledDepthFromPixelsSized(drawLanternCourierRight(), 12, 16), lightPixels: [{ x: 8, y: 9 }], lightColor: '#ffe99a' }), makeAssetSized('Plaza Musician', 'dynamic', 'human', drawPlazaMusicianRight(), 14, 16, { depthPixels: filledDepthFromPixelsSized(drawPlazaMusicianRight(), 14, 16) }), makeAssetSized('Bridge Mechanic', 'dynamic', 'human', drawBridgeMechanicRight(), 14, 16, { depthPixels: filledDepthFromPixelsSized(drawBridgeMechanicRight(), 14, 16) }), makeAssetSized('Harbor Clocktower', 'static', 'building', drawHarborClocktower(), 32, 40, { depthPixels: filledDepthFromPixelsSized(drawHarborClocktower(), 32, 40), lightPixels: [{ x: 15, y: 12 }, { x: 16, y: 12 }, { x: 13, y: 22 }, { x: 18, y: 22 }, { x: 14, y: 28 }, { x: 17, y: 28 }], lightColor: '#ffe99a', door: { x: 15, y: 38 } }), makeAssetSized('Glass Greenhouse', 'static', 'building', drawGlassGreenhouse(), 28, 20, { depthPixels: filledDepthFromPixelsSized(drawGlassGreenhouse(), 28, 20), lightPixels: [{ x: 10, y: 10 }, { x: 13, y: 10 }, { x: 16, y: 10 }], lightColor: '#fff4c2', door: { x: 13, y: 18 } }), makeAssetSized('Steam Workshop', 'static', 'building', drawSteamWorkshop(), 40, 24, { depthPixels: filledDepthFromPixelsSized(drawSteamWorkshop(), 40, 24), lightPixels: [{ x: 13, y: 14 }, { x: 18, y: 14 }, { x: 27, y: 14 }, { x: 32, y: 14 }], lightColor: '#ffd979', door: { x: 20, y: 22 } }), makeAssetSized('Canal Bridge', 'static', 'building', drawCanalBridge(), 48, 16, { depthPixels: filledDepthFromPixelsSized(drawCanalBridge(), 48, 16) }), makeAssetSized('Grand Fountain', 'static', 'building', drawGrandFountain(), 24, 24, { depthPixels: filledDepthFromPixelsSized(drawGrandFountain(), 24, 24), lightPixels: [{ x: 11, y: 7 }, { x: 12, y: 7 }, { x: 9, y: 12 }, { x: 14, y: 12 }], lightColor: '#dffbff', particlePixels: [{ x: 11, y: 5, c: nearestPaletteCode('#eafcff'), dir: 'up' }, { x: 12, y: 5, c: nearestPaletteCode('#eafcff'), dir: 'up' }] }), makeAssetSized('Rocket Monument', 'static', 'building', drawRocketMonument(), 20, 32, { depthPixels: filledDepthFromPixelsSized(drawRocketMonument(), 20, 32), lightPixels: [{ x: 9, y: 8 }, { x: 10, y: 8 }, { x: 8, y: 24 }, { x: 11, y: 24 }, { x: 9, y: 27 }, { x: 10, y: 27 }], lightColor: '#ffdca6', particlePixels: [{ x: 8, y: 27, c: nearestPaletteCode('#ffd979'), dir: 'up' }, { x: 11, y: 27, c: nearestPaletteCode('#ff8a5c'), dir: 'up' }], door: { x: 9, y: 30 } }), makeAssetSized('Arcade Booth', 'static', 'building', drawArcadeBooth(), 24, 20, { depthPixels: filledDepthFromPixelsSized(drawArcadeBooth(), 24, 20), lightPixels: [{ x: 7, y: 10 }, { x: 16, y: 10 }, { x: 11, y: 6 }], lightColor: '#98d8ff', door: { x: 11, y: 18 } }), makeAssetSized('Chess Knight Statue', 'static', 'building', drawChessKnightStatue(), 16, 24, { depthPixels: filledDepthFromPixelsSized(drawChessKnightStatue(), 16, 24), lightPixels: [{ x: 7, y: 8 }, { x: 8, y: 8 }], lightColor: '#dffbff' }), makeAssetSized('Desert Train', 'static', 'building', drawDesertTrain(), 44, 16, { depthPixels: filledDepthFromPixelsSized(drawDesertTrain(), 44, 16), lightPixels: [{ x: 7, y: 7 }, { x: 13, y: 8 }, { x: 19, y: 8 }, { x: 28, y: 8 }, { x: 35, y: 8 }], lightColor: '#ffd979', particlePixels: [{ x: 5, y: 3, c: nearestPaletteCode('#d9e4e8'), dir: 'up' }, { x: 7, y: 2, c: nearestPaletteCode('#d9e4e8'), dir: 'up' }] }), makeAssetSized('Tea Robot', 'dynamic', 'human', drawTeaRobotRight(), 12, 16, { depthPixels: filledDepthFromPixelsSized(drawTeaRobotRight(), 12, 16), lightPixels: [{ x: 8, y: 8 }], lightColor: '#9be6ff' }), makeAssetSized('Balloon Vendor', 'dynamic', 'human', drawBalloonVendorRight(), 14, 18, { depthPixels: filledDepthFromPixelsSized(drawBalloonVendorRight(), 14, 18) }), makeAssetSized('Jelly Comet', 'dynamic', 'animal', drawJellyCometRight(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawJellyCometRight(), 12, 12), lightPixels: [{ x: 7, y: 4 }], lightColor: '#b8d6ff', particlePixels: [{ x: 2, y: 6, c: nearestPaletteCode('#d8f0ff'), dir: 'up' }] }), makeAssetSized('Courier Bike', 'dynamic', 'human', drawCourierBikeRight(), 16, 12, { depthPixels: filledDepthFromPixelsSized(drawCourierBikeRight(), 16, 12) }), makeAssetSized('Great Cedar', 'static', 'nature', drawGreatCedar(), 24, 32, { depthPixels: drawGreatCedarDepth() }), makeAssetSized('Moonfall Cascade', 'static', 'nature', drawMoonfallCascade(), 24, 32, { depthPixels: drawMoonfallCascadeDepth(), particlePixels: [{ x: 9, y: 20, c: nearestPaletteCode('#dffbff'), dir: 'up' }, { x: 14, y: 20, c: nearestPaletteCode('#dffbff'), dir: 'up' }] }), makeAssetSized('Sunblossom Gate', 'static', 'nature', drawSunblossomGate(), 24, 24, { depthPixels: drawSunblossomGateDepth() }), makeAssetSized('Echo Cavern', 'static', 'nature', drawEchoCavern(), 32, 20, { depthPixels: drawEchoCavernDepth() }), makeAssetSized('Worldroot Shrine', 'static', 'nature', drawWorldrootShrine(), 28, 28, { depthPixels: drawWorldrootShrineDepth(), lightPixels: [{ x: 13, y: 12 }, { x: 14, y: 12 }, { x: 12, y: 13 }, { x: 15, y: 13 }], lightColor: '#ffe99a', door: { x: 14, y: 24 } }), makeAssetSized('Moon Jelly Aquarium', 'static', 'building', drawMoonJellyAquarium(), 24, 28, { depthPixels: drawMoonJellyAquariumDepth(), lightPixels: [{ x: 11, y: 0 }, { x: 12, y: 0 }, { x: 10, y: 10 }, { x: 13, y: 10 }, { x: 11, y: 20 }, { x: 12, y: 20 }], lightColor: '#ffe7a6', door: { x: 11, y: 24 } }), makeAssetSized('Aurora Observatory', 'static', 'building', drawAuroraObservatory(), 28, 28, { depthPixels: filledDepthFromPixelsSized(drawAuroraObservatory(), 28, 28), lightPixels: [{ x: 13, y: 2 }, { x: 14, y: 2 }, { x: 9, y: 13 }, { x: 18, y: 13 }, { x: 13, y: 23 }], lightColor: '#d9eeff', door: { x: 13, y: 24 } }), makeAssetSized('Hearthwind Mill', 'static', 'building', drawHearthwindMill(), 24, 28, { depthPixels: filledDepthFromPixelsSized(drawHearthwindMill(), 24, 28), lightPixels: [{ x: 8, y: 13 }, { x: 15, y: 13 }, { x: 11, y: 18 }], lightColor: '#ffe6a5', door: { x: 11, y: 22 } }), makeAssetSized('Tideglass Lighthouse', 'static', 'building', drawTideglassLighthouse(), 20, 36, { depthPixels: filledDepthFromPixelsSized(drawTideglassLighthouse(), 20, 36), lightPixels: [{ x: 8, y: 2 }, { x: 9, y: 2 }, { x: 8, y: 16 }, { x: 8, y: 24 }], lightColor: '#f8f0bb', door: { x: 8, y: 33 } }), makeAssetSized('Mossrail Depot', 'static', 'building', drawMossrailDepot(), 32, 20, { depthPixels: filledDepthFromPixelsSized(drawMossrailDepot(), 32, 20), lightPixels: [{ x: 6, y: 8 }, { x: 13, y: 8 }, { x: 20, y: 8 }, { x: 26, y: 8 }], lightColor: '#ffe1a0', door: { x: 15, y: 16 } }), makeAssetSized('Lantern Library', 'static', 'building', drawLanternLibrary(), 28, 24, { depthPixels: filledDepthFromPixelsSized(drawLanternLibrary(), 28, 24), lightPixels: [{ x: 8, y: 7 }, { x: 19, y: 7 }, { x: 13, y: 16 }, { x: 14, y: 16 }], lightColor: '#ffe8b0', door: { x: 13, y: 18 } }), makeAssetSized('Velvet Theater', 'static', 'building', drawVelvetTheater(), 28, 20, { depthPixels: filledDepthFromPixelsSized(drawVelvetTheater(), 28, 20), lightPixels: [{ x: 5, y: 1 }, { x: 9, y: 1 }, { x: 13, y: 1 }, { x: 17, y: 1 }, { x: 21, y: 1 }, { x: 13, y: 14 }], lightColor: '#ffd19a', door: { x: 13, y: 16 } }), makeAssetSized('Bloom Bridge Gate', 'static', 'nature', drawBloomBridgeGate(), 24, 24, { depthPixels: filledDepthFromPixelsSized(drawBloomBridgeGate(), 24, 24), particlePixels: [{ x: 7, y: 2, c: nearestPaletteCode('#ffd4ea'), dir: 'up' }, { x: 16, y: 2, c: nearestPaletteCode('#ffd4ea'), dir: 'up' }] }), makeAssetSized('Starreef Submarine', 'static', 'ship', drawStarreefSubmarine(), 32, 16, { depthPixels: filledDepthFromPixelsSized(drawStarreefSubmarine(), 32, 16), lightPixels: [{ x: 12, y: 3 }, { x: 15, y: 7 }, { x: 20, y: 7 }], lightColor: '#c5f1ff' }), makeAssetSized('Astrolabe Courier', 'dynamic', 'human', drawAstrolabeCourierRight(), 12, 16, { depthPixels: filledDepthFromPixelsSized(drawAstrolabeCourierRight(), 12, 16), lightPixels: [{ x: 6, y: 5 }], lightColor: '#d9eeff' }), makeAssetSized('Puddle Toad', 'dynamic', 'animal', drawPuddleToadRight(), 12, 10, { depthPixels: filledDepthFromPixelsSized(drawPuddleToadRight(), 12, 10) }), makeAssetSized('Ribbon Swan', 'dynamic', 'bird', drawRibbonSwanRight(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawRibbonSwanRight(), 12, 12) }), makeAssetSized('Lantern Beetle', 'dynamic', 'animal', drawLanternBeetleRight(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawLanternBeetleRight(), 12, 12), lightPixels: [{ x: 5, y: 3 }], lightColor: '#fff1a8' }), makeAssetSized('Smile Portrait', 'static', 'building', drawSmilePortrait(), 24, 32, { depthPixels: filledDepthFromPixelsSized(drawSmilePortrait(), 24, 32) }), makeAssetSized('Great Wave Panel', 'static', 'nature', drawGreatWavePanel(), 32, 24, { depthPixels: filledDepthFromPixelsSized(drawGreatWavePanel(), 32, 24) }), makeAssetSized('Sunflower Still Life', 'static', 'nature', drawSunflowerStillLife(), 24, 32, { depthPixels: filledDepthFromPixelsSized(drawSunflowerStillLife(), 24, 32) }), makeAssetSized('Rosetta Stela', 'static', 'building', drawRosettaStela(), 24, 32, { depthPixels: filledDepthFromPixelsSized(drawRosettaStela(), 24, 32) }), makeAssetSized('Pharaoh Mask', 'static', 'building', drawPharaohMask(), 24, 32, { depthPixels: filledDepthFromPixelsSized(drawPharaohMask(), 24, 32) }), makeAssetSized('Stone Circle', 'static', 'nature', drawStoneCircle(), 32, 24, { depthPixels: filledDepthFromPixelsSized(drawStoneCircle(), 32, 24) }), makeAssetSized('Terracotta Sentinel', 'static', 'building', drawTerracottaSentinel(), 20, 32, { depthPixels: filledDepthFromPixelsSized(drawTerracottaSentinel(), 20, 32) }), makeAssetSized('Rain Bell Tower', 'static', 'building', drawRainBellTower(), 20, 36, { depthPixels: filledDepthFromPixelsSized(drawRainBellTower(), 20, 36), lightPixels: [{ x: 9, y: 11 }, { x: 9, y: 19 }], lightColor: '#fff0b8', door: { x: 9, y: 33 } }), makeAssetSized('Crimson Pagoda', 'static', 'building', drawCrimsonPagoda(), 24, 32, { depthPixels: filledDepthFromPixelsSized(drawCrimsonPagoda(), 24, 32), lightPixels: [{ x: 8, y: 12 }, { x: 15, y: 12 }, { x: 11, y: 22 }], lightColor: '#ffd38a', door: { x: 11, y: 28 } }), makeAssetSized('Meteor Forge', 'static', 'building', drawMeteorForge(), 32, 24, { depthPixels: filledDepthFromPixelsSized(drawMeteorForge(), 32, 24), lightPixels: [{ x: 8, y: 10 }, { x: 23, y: 10 }, { x: 15, y: 16 }], lightColor: '#ffb36b', door: { x: 15, y: 19 } }), makeAssetSized('Ink Garden Screen', 'static', 'nature', drawInkGardenScreen(), 32, 24, { depthPixels: filledDepthFromPixelsSized(drawInkGardenScreen(), 32, 24) }), makeAssetSized('Meadow Totem', 'static', 'nature', drawMeadowTotem(), 20, 28, { depthPixels: filledDepthFromPixelsSized(drawMeadowTotem(), 20, 28) }), makeAssetSized('Twilight Caravan', 'static', 'ship', drawTwilightCaravan(), 32, 16, { depthPixels: filledDepthFromPixelsSized(drawTwilightCaravan(), 32, 16), lightPixels: [{ x: 9, y: 7 }, { x: 15, y: 7 }, { x: 22, y: 7 }], lightColor: '#ffdba0' }), makeAssetSized('Pepper Fox', 'dynamic', 'animal', drawPepperFoxRight(), 16, 16, { depthPixels: filledDepthFromPixelsSized(drawPepperFoxRight(), 16, 16) }), makeAssetSized('Glass Manta', 'dynamic', 'fish', drawGlassMantaRight(), 16, 12, { depthPixels: filledDepthFromPixelsSized(drawGlassMantaRight(), 16, 12), lightPixels: [{ x: 7, y: 5 }], lightColor: '#9ef4ff' }), makeAssetSized('Festival Drummer', 'dynamic', 'human', drawFestivalDrummerRight(), 14, 16, { depthPixels: filledDepthFromPixelsSized(drawFestivalDrummerRight(), 14, 16) }), makeAssetSized('Bloom Sprite', 'dynamic', 'animal', drawBloomSpriteRight(), 12, 12, { depthPixels: filledDepthFromPixelsSized(drawBloomSpriteRight(), 12, 12), lightPixels: [{ x: 5, y: 3 }], lightColor: '#ffe7a8' }) ]; const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id])); const assetById = new Map(assets.map((asset) => [asset.id, asset])); const removedLowQualitySeedAssetIds = new Set([...REMOVED_LOW_QUALITY_SEED_ASSET_NAMES].map((name) => idByName[name]).filter(Boolean)); const normalizeSeedPlacementTerrain = (object) => { const asset = assetById.get(object.assetId); if (!asset || asset.category !== 'static') return object; const tile = world.get(object.x, object.y); const wantsWater = asset.subtype === 'water' || asset.subtype === 'ship'; const valid = tile && (wantsWater ? tile.type === 'water' : tile.type !== 'water'); if (valid) return object; const target = wantsWater ? findNearestTerrain('water', object.x, object.y) : findNearestLandTerrain(object.x, object.y); return { ...object, x: target.x, y: target.y }; }; const applySeedQualityFilter = (gallery) => ({ ...gallery, assets: gallery.assets .filter((asset) => !removedLowQualitySeedAssetIds.has(asset.id)) .map((asset) => ({ ...asset, author: 'Local Artist', ownerAccountId: '' })), placed: gallery.placed .filter((object) => !removedLowQualitySeedAssetIds.has(object.assetId)) .map(normalizeSeedPlacementTerrain) .map((object) => ({ ...object, ownerAccountId: '' })), dynamicSummons: gallery.dynamicSummons .filter((object) => !removedLowQualitySeedAssetIds.has(object.assetId)) .map((object) => ({ ...object, ownerAccountId: '' })) }); const whalePos = findNearestTerrain('water', 62, 58); const koiPos = findNearestTerrain('water', 66, 60); const skiffPos = findNearestTerrain('water', 56, 58); const minnowPos = findNearestTerrain('water', 71, 57); const turtlePos = findNearestTerrain('water', 53, 60); const sunsetKoiPos = findNearestTerrain('water', 60, 61); const troutPos = findNearestTerrain('water', 75, 56); const butterflyFishPos = findNearestTerrain('water', 68, 63); const starreefPos = findNearestTerrain('water', 118, 84); return applySeedQualityFilter({ assets, placed: [ { id: uid(), assetId: idByName['Crescent Tea House'], ownerAccountId: 'island-team', x: 36, y: 36, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Prism Sakura'], ownerAccountId: 'island-team', x: 32, y: 38, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Clockwork Whale'], ownerAccountId: 'island-team', ...whalePos, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Moon Lantern'], ownerAccountId: 'island-team', x: 41, y: 37, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Glass Fern'], ownerAccountId: 'island-team', x: 29, y: 40, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Linen Cottage'], ownerAccountId: 'island-team', x: 45, y: 40, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Pine Cluster'], ownerAccountId: 'island-team', x: 47, y: 34, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Shell Rock'], ownerAccountId: 'island-team', x: 40, y: 43, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Coral Skiff'], ownerAccountId: 'island-team', ...skiffPos, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Wildflower Patch'], ownerAccountId: 'island-team', x: 50, y: 39, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['River Stones'], ownerAccountId: 'island-team', x: 52, y: 41, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Maple Canopy'], ownerAccountId: 'island-team', x: 25, y: 34, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Misty Falls'], ownerAccountId: 'island-team', x: 59, y: 33, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Lotus Pond'], ownerAccountId: 'island-team', x: 53, y: 36, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Mossy Arch'], ownerAccountId: 'island-team', x: 20, y: 38, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Sunflower Grove'], ownerAccountId: 'island-team', x: 24, y: 43, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Reed Bed'], ownerAccountId: 'island-team', x: 58, y: 40, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Harbor Clocktower'], ownerAccountId: 'island-team', x: 108, y: 34, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Glass Greenhouse'], ownerAccountId: 'island-team', x: 87, y: 38, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Steam Workshop'], ownerAccountId: 'island-team', x: 78, y: 47, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Canal Bridge'], ownerAccountId: 'island-team', x: 84, y: 60, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Grand Fountain'], ownerAccountId: 'island-team', x: 96, y: 52, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Rocket Monument'], ownerAccountId: 'island-team', x: 117, y: 46, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Arcade Booth'], ownerAccountId: 'island-team', x: 101, y: 58, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Chess Knight Statue'], ownerAccountId: 'island-team', x: 92, y: 53, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Desert Train'], ownerAccountId: 'island-team', x: 23, y: 68, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Great Cedar'], ownerAccountId: 'island-team', x: 16, y: 39, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Moonfall Cascade'], ownerAccountId: 'island-team', x: 72, y: 35, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Sunblossom Gate'], ownerAccountId: 'island-team', x: 34, y: 47, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Echo Cavern'], ownerAccountId: 'island-team', x: 98, y: 42, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Worldroot Shrine'], ownerAccountId: 'island-team', x: 58, y: 31, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Moon Jelly Aquarium'], ownerAccountId: 'island-team', x: 112, y: 38, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Aurora Observatory'], ownerAccountId: 'island-team', x: 70, y: 26, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Hearthwind Mill'], ownerAccountId: 'island-team', x: 16, y: 56, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Tideglass Lighthouse'], ownerAccountId: 'island-team', x: 122, y: 28, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Mossrail Depot'], ownerAccountId: 'island-team', x: 104, y: 64, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Lantern Library'], ownerAccountId: 'island-team', x: 38, y: 29, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Velvet Theater'], ownerAccountId: 'island-team', x: 86, y: 31, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Bloom Bridge Gate'], ownerAccountId: 'island-team', x: 73, y: 81, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Starreef Submarine'], ownerAccountId: 'island-team', ...starreefPos, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Smile Portrait'], ownerAccountId: 'island-team', x: 13, y: 55, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Great Wave Panel'], ownerAccountId: 'island-team', x: 68, y: 51, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Sunflower Still Life'], ownerAccountId: 'island-team', x: 34, y: 80, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Rosetta Stela'], ownerAccountId: 'island-team', x: 122, y: 74, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Pharaoh Mask'], ownerAccountId: 'island-team', x: 93, y: 27, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Stone Circle'], ownerAccountId: 'island-team', x: 73, y: 87, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Terracotta Sentinel'], ownerAccountId: 'island-team', x: 111, y: 69, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Rain Bell Tower'], ownerAccountId: 'island-team', x: 126, y: 43, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Crimson Pagoda'], ownerAccountId: 'island-team', x: 26, y: 35, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Meteor Forge'], ownerAccountId: 'island-team', x: 47, y: 66, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Ink Garden Screen'], ownerAccountId: 'island-team', x: 86, y: 77, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Meadow Totem'], ownerAccountId: 'island-team', x: 22, y: 76, placedAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Twilight Caravan'], ownerAccountId: 'island-team', x: 18, y: 90, placedAt: now, publishedAt: now, version: 1 } ], dynamicSummons: [ { id: uid(), assetId: idByName['Lantern Cat'], ownerAccountId: 'island-team', homeX: 38, homeY: 39, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Cloud Koi'], ownerAccountId: 'island-team', ...homeFromPos(koiPos), createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Paper Crane'], ownerAccountId: 'island-team', homeX: 91, homeY: 31, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Sprout Fox'], ownerAccountId: 'island-team', homeX: 46, homeY: 40, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Violet Moth'], ownerAccountId: 'island-team', homeX: 31, homeY: 35, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Lantern Walker'], ownerAccountId: 'island-team', homeX: 34, homeY: 34, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Azure Minnow'], ownerAccountId: 'island-team', ...homeFromPos(minnowPos), createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Firefly Swirl'], ownerAccountId: 'island-team', homeX: 26, homeY: 37, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Meadow Hare'], ownerAccountId: 'island-team', homeX: 43, homeY: 46, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Brook Turtle'], ownerAccountId: 'island-team', ...homeFromPos(turtlePos), createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Leaf Sparrow'], ownerAccountId: 'island-team', homeX: 23, homeY: 34, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Red Panda'], ownerAccountId: 'island-team', homeX: 27, homeY: 41, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['River Otter'], ownerAccountId: 'island-team', homeX: 62, homeY: 42, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Amber Deer'], ownerAccountId: 'island-team', homeX: 18, homeY: 46, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Forest Owl'], ownerAccountId: 'island-team', homeX: 21, homeY: 33, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Kingfisher'], ownerAccountId: 'island-team', homeX: 57, homeY: 39, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Pond Duck'], ownerAccountId: 'island-team', homeX: 54, homeY: 38, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Heron'], ownerAccountId: 'island-team', homeX: 64, homeY: 36, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Sunset Koi'], ownerAccountId: 'island-team', ...homeFromPos(sunsetKoiPos), createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Silver Trout'], ownerAccountId: 'island-team', ...homeFromPos(troutPos), createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Butterfly Fish'], ownerAccountId: 'island-team', ...homeFromPos(butterflyFishPos), createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Town Gardener'], ownerAccountId: 'island-team', homeX: 89, homeY: 39, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Lantern Courier'], ownerAccountId: 'island-team', homeX: 110, homeY: 46, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Plaza Musician'], ownerAccountId: 'island-team', homeX: 97, homeY: 53, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Bridge Mechanic'], ownerAccountId: 'island-team', homeX: 84, homeY: 58, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Tea Robot'], ownerAccountId: 'island-team', homeX: 104, homeY: 59, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Balloon Vendor'], ownerAccountId: 'island-team', homeX: 98, homeY: 58, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Jelly Comet'], ownerAccountId: 'island-team', homeX: 112, homeY: 53, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Courier Bike'], ownerAccountId: 'island-team', homeX: 88, homeY: 60, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Astrolabe Courier'], ownerAccountId: 'island-team', homeX: 72, homeY: 28, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Puddle Toad'], ownerAccountId: 'island-team', homeX: 74, homeY: 79, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Ribbon Swan'], ownerAccountId: 'island-team', homeX: 56, homeY: 37, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Lantern Beetle'], ownerAccountId: 'island-team', homeX: 40, homeY: 30, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Pepper Fox'], ownerAccountId: 'island-team', homeX: 28, homeY: 71, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Glass Manta'], ownerAccountId: 'island-team', homeX: 122, homeY: 86, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Festival Drummer'], ownerAccountId: 'island-team', homeX: 52, homeY: 67, createdAt: now, publishedAt: now, version: 1 }, { id: uid(), assetId: idByName['Bloom Sprite'], ownerAccountId: 'island-team', homeX: 92, homeY: 79, createdAt: now, publishedAt: now, version: 1 } ] }); } function mergeDefaultGallery(baseState) { if (!baseState || typeof baseState !== 'object') return seedState(); const seeded = buildDefaultGalleryPack(Date.now()); baseState.assets = Array.isArray(baseState.assets) ? baseState.assets : []; baseState.placed = Array.isArray(baseState.placed) ? baseState.placed : []; baseState.dynamicSummons = Array.isArray(baseState.dynamicSummons) ? baseState.dynamicSummons : []; baseState.deletedSeedAssetNames = Array.isArray(baseState.deletedSeedAssetNames) ? baseState.deletedSeedAssetNames : []; const removedExistingSeedAssetIds = new Set(baseState.assets .filter((asset) => REMOVED_LOW_QUALITY_SEED_ASSET_NAMES.has(asset.name) && (asset.ownerAccountId === 'island-team' || asset.author === 'Island Team')) .map((asset) => asset.id)); if (removedExistingSeedAssetIds.size) { baseState.assets = baseState.assets.filter((asset) => !removedExistingSeedAssetIds.has(asset.id)); baseState.placed = baseState.placed.filter((object) => !removedExistingSeedAssetIds.has(object.assetId)); baseState.dynamicSummons = baseState.dynamicSummons.filter((object) => !removedExistingSeedAssetIds.has(object.assetId)); for (const assetId of removedExistingSeedAssetIds) { delete baseState.hiddenAssets?.[assetId]; delete baseState.assetVotes?.[assetId]; } } const assetByName = new Map(baseState.assets.map((asset) => [asset.name, asset])); const assetIdByName = new Map(baseState.assets.map((asset) => [asset.name, asset.id])); const tombstonedDefaultNames = new Set(Object.values(baseState.tombstones?.assets || {}) .filter((tombstone) => tombstone?.name) .map((tombstone) => tombstone.name)); for (const name of baseState.deletedSeedAssetNames) tombstonedDefaultNames.add(name); for (const seededAsset of seeded.assets) { if (tombstonedDefaultNames.has(seededAsset.name)) continue; const existing = assetByName.get(seededAsset.name); if (!existing) { baseState.assets.push(seededAsset); assetByName.set(seededAsset.name, seededAsset); assetIdByName.set(seededAsset.name, seededAsset.id); continue; } assetIdByName.set(seededAsset.name, existing.id); } const seededNameById = new Map(seeded.assets.map((asset) => [asset.id, asset.name])); const hasPlacedAsset = new Set(baseState.placed.filter((item) => item).map((item) => item.assetId)); for (const item of seeded.placed) { const name = seededNameById.get(item.assetId); const resolvedAssetId = assetIdByName.get(name); if (resolvedAssetId && !hasPlacedAsset.has(resolvedAssetId)) { baseState.placed.push({ ...item, id: uid(), assetId: resolvedAssetId }); hasPlacedAsset.add(resolvedAssetId); } } const hasDynamicAsset = new Set(baseState.dynamicSummons.filter((item) => item).map((item) => item.assetId)); for (const item of seeded.dynamicSummons) { const name = seededNameById.get(item.assetId); const resolvedAssetId = assetIdByName.get(name); if (resolvedAssetId && !hasDynamicAsset.has(resolvedAssetId)) { baseState.dynamicSummons.push({ ...item, id: uid(), assetId: resolvedAssetId }); hasDynamicAsset.add(resolvedAssetId); } } return baseState; } function seedState() { const now = Date.now(); const gallery = buildDefaultGalleryPack(now); return { schema: SAVE_SCHEMA, authorName: 'Local Artist', assets: gallery.assets, placed: gallery.placed, objectVotes: {}, assetVotes: {}, hiddenAssets: {}, hiddenObjects: {}, moderationReports: [], guardrails: { ...PHASE5_GUARDRAILS }, settings: defaultVisualSettings(), account: null, publishLog: [], worldMode: 'local', serverSync: { lastServerEventId: null, pendingCommands: [], authority: { ...DEFAULT_SERVER_AUTHORITY }, clock: { worldTimeMs: now, syncedAt: now, dayMs: DAY_MS }, dynamicTargets: {}, pendingObjectVisuals: {} }, tombstones: { assets: {}, objects: {} }, deletedSeedAssetNames: [], dynamicSummons: gallery.dynamicSummons }; } function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) { return makeAssetSized(name, category, subtype, pixels, 16, 16, meta, leftPixels); } function makeAssetSized(name, category, subtype, pixels, width = 16, height = width, meta = {}, leftPixels = null) { const w = clampDimension(width, 16); const h = clampDimension(height, w); const size = Math.max(w, h); const id = uid(); const right = alignPixelsToBottomRect(normalizePixels(pixels, w, h), w, h); const left = leftPixels ? alignPixelsToBottomRect(normalizePixels(leftPixels, w, h), w, h) : null; const asset = { id, name, category, subtype, size, width: w, height: h, pixels: encodePixels(right, w, h), faces: category === 'dynamic' ? { right: encodePixels(right, w, h), left: left ? encodePixels(left, w, h) : 'mirror' } : null, parentAssetId: null, originalAssetId: null, createdAt: Date.now(), updatedAt: Date.now(), author: 'Local Artist', ownerAccountId: '', version: 1, meta: { ...buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, w, meta.particleConfig || meta.particlePixels || [], right, h), galleryOrigin: 'default-gallery' } }; asset.contentHash = computeAssetContentHash(asset); return asset; } function alignPixelsToBottom(pixels, size) { return alignPixelsToBottomRect(pixels, size, size); } function alignPixelsToBottomRect(pixels, width, height = width) { const source = normalizePixels(pixels, width, height); let maxY = -1; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (source[y * width + x]) maxY = Math.max(maxY, y); } } if (maxY < 0 || maxY === height - 1) return source; const dy = height - 1 - maxY; const out = blankPixels(width, height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const value = source[y * width + x]; if (value && y + dy < height) out[(y + dy) * width + x] = value; } } return out; } function recordSyncEvent(event) { if (!event) return; if (isSharedWorld()) { const command = sharedCommandFromLocalEvent(event); if (command) queueSharedCommand(command); return; } state.eventLog ||= []; state.eventLog.push(event); state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)); state.sync ||= { lastEventId: null }; state.sync.lastEventId = event.id; } function sharedCommandFromLocalEvent(event) { if (!event?.type) return null; if (event.type === 'asset.upsert' && event.asset) return makeSharedCommand('asset.create', { asset: Phase2Sync?.unpackAsset ? Phase2Sync.unpackAsset(event.asset) : event.asset }); if (event.type === 'asset.delete' && event.assetId) return makeSharedCommand('asset.delete', { assetId: event.assetId }); if (event.type === 'object.upsert' && event.object) { const object = event.kind === 'dynamic' ? Phase2Sync?.unpackDynamic?.(event.object) || event.object : Phase2Sync?.unpackPlacement?.(event.object) || event.object; return makeSharedCommand('object.publish', { kind: event.kind === 'dynamic' ? 'dynamic' : 'static', object }); } if (event.type === 'object.delete' && event.objectId) return makeSharedCommand('object.delete', { kind: event.kind === 'dynamic' ? 'dynamic' : 'static', objectId: event.objectId }); return null; } function applySyncEvent(event) { if (!event) return false; if (isSharedWorld() && !event.serverEventId) { console.warn('Rejected non-server sync event in shared world.', event); return false; } const unpackObject = (kind, object) => kind === 'dynamic' ? Phase2Sync?.unpackDynamic?.(object) || object : Phase2Sync?.unpackPlacement?.(object) || object; if (isSharedWorld()) applyServerIssuedEvent(event, unpackObject); else if (StateIndex?.reduce) StateIndex.reduce(state, event, { unpackAsset: Phase2Sync?.unpackAsset, unpackObject }); else Phase2Sync?.applyEvent?.(state, event); rebuildWorldIndex(); if (isSharedWorld() && (event.type === 'dynamic.move' || (event.type === 'object.upsert' && event.kind === 'dynamic'))) hydrateRuntime(); spriteCache.clear(); state.sync ||= { lastEventId: null }; state.sync.lastEventId = event.id || state.sync.lastEventId; return true; } function applyServerIssuedEvent(event, unpackObject) { ensureWorldProtectionState(); if (event.type === 'world.phase') { state.serverSync.clock = { worldTimeMs: Number(event.worldTimeMs ?? event.serverNow ?? event.serverAt) || Date.now(), syncedAt: Date.now(), dayMs: Number(event.dayMs) || DAY_MS, phase: event.phase || null, serverEventId: event.serverEventId || null }; } else if (event.type === 'dynamic.move') { const object = event.object ? unpackObject('dynamic', event.object) : null; const objectId = String(event.objectId || event.dynamicId || object?.id || ''); if (!objectId || isObjectTombstoned(objectId, Number(object?.version || event.objectVersion || 1))) return; const target = { objectId, x: Number(event.x ?? object?.serverState?.x ?? object?.x ?? object?.homeX ?? 0), y: Number(event.y ?? object?.serverState?.y ?? object?.y ?? object?.homeY ?? 0), targetX: Number(event.targetX ?? object?.serverState?.targetX ?? event.x ?? object?.x ?? object?.homeX ?? 0), targetY: Number(event.targetY ?? object?.serverState?.targetY ?? event.y ?? object?.y ?? object?.homeY ?? 0), homeX: Number(event.homeX ?? object?.homeX ?? 0), homeY: Number(event.homeY ?? object?.homeY ?? 0), facing: Number(event.facing ?? object?.serverState?.facing ?? 1) || 1, serverAt: Number(event.serverAt) || Date.now() }; state.serverSync.dynamicTargets[objectId] = target; delete state.serverSync.pendingObjectVisuals?.[objectId]; if (object?.id) { const normalized = normalizeDynamicSummons([{ ...object, serverState: target }])[0]; const index = state.dynamicSummons.findIndex((item) => item.id === normalized.id); if (index >= 0) state.dynamicSummons[index] = normalized; else state.dynamicSummons.push(normalized); } } else if (event.type === 'asset.upsert' && event.asset) { const asset = event.asset.p && Phase2Sync?.unpackAsset ? Phase2Sync.unpackAsset(event.asset) : event.asset; if (!asset?.id || isAssetTombstoned(asset.id, asset.version || event.assetVersion || 1)) return; const index = state.assets.findIndex((item) => item.id === asset.id); if (index >= 0) state.assets[index] = normalizeAsset(asset); else state.assets.unshift(normalizeAsset(asset)); } else if (event.type === 'object.upsert' && event.object) { const kind = event.kind === 'dynamic' ? 'dynamic' : 'static'; const object = unpackObject(kind, event.object); if (!object?.id || isObjectTombstoned(object.id, object.version || event.objectVersion || 1)) return; const list = kind === 'dynamic' ? state.dynamicSummons : state.placed; const normalized = kind === 'dynamic' ? normalizeDynamicSummons([object])[0] : normalizePlacements([object])[0]; const index = list.findIndex((item) => item.id === normalized.id); if (index >= 0) list[index] = normalized; else list.push(normalized); delete state.serverSync.pendingObjectVisuals?.[normalized.id]; } else if (event.type === 'object.delete' && event.objectId) { const kind = event.kind === 'dynamic' ? 'dynamic' : 'static'; const tombstone = event.tombstone || { id: event.objectId, deletedAt: event.serverAt, deletedBy: event.actorAccountId, version: event.objectVersion || 1 }; state.tombstones.objects[event.objectId] = tombstone; if (kind === 'dynamic') state.dynamicSummons = state.dynamicSummons.filter((item) => item.id !== event.objectId); else state.placed = state.placed.filter((item) => item.id !== event.objectId); delete state.serverSync.pendingObjectVisuals?.[event.objectId]; delete state.objectVotes?.[event.objectId]; delete state.hiddenObjects?.[event.objectId]; state.moderationReports = (state.moderationReports || []).filter((report) => report.objectId !== event.objectId); } else if (event.type === 'asset.delete' && event.assetId) { state.tombstones.assets[event.assetId] = event.tombstone || { id: event.assetId, deletedAt: event.serverAt, deletedBy: event.actorAccountId, version: event.assetVersion || 1 }; const removedObjectIds = new Set((event.objectTombstones || []).map((tombstone) => { if (tombstone?.id) state.tombstones.objects[tombstone.id] = tombstone; return tombstone?.id; }).filter(Boolean)); state.assets = state.assets.filter((asset) => asset.id !== event.assetId); state.placed = state.placed.filter((item) => !removedObjectIds.has(item.id)); state.dynamicSummons = state.dynamicSummons.filter((item) => !removedObjectIds.has(item.id)); delete state.assetVotes?.[event.assetId]; delete state.hiddenAssets?.[event.assetId]; for (const id of removedObjectIds) { delete state.objectVotes?.[id]; delete state.hiddenObjects?.[id]; } state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId)); } state.serverSync.lastServerEventId = event.serverEventId || state.serverSync.lastServerEventId; } function cachePhase2State() { if (!Phase2Sync?.cacheAssets) return; Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error)); 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 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 computeAssetContentHash(asset) { const payload = [ asset.category || '', asset.subtype || '', asset.size || '', asset.width || '', asset.height || '', asset.pixels || '', asset.faces?.right || '', asset.faces?.left || '', asset.meta?.depthPixels || '', JSON.stringify(asset.meta?.lightPixels || []), JSON.stringify(asset.meta?.particlePixels || []), JSON.stringify(asset.meta?.particleConfig || null), asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : '' ].join('|'); return `fnv1a:${fnv1a(payload)}`; } function findEquivalentCollectionAsset(asset) { if (!asset?.contentHash) return null; const owner = normalizeOwnerAccountId(asset.ownerAccountId, currentAccountId()); return (state.assets || []).find((item) => item.id !== asset.id && item.contentHash === asset.contentHash && item.name === asset.name && item.category === asset.category && item.subtype === asset.subtype && assetWidth(item) === assetWidth(asset) && assetHeight(item) === assetHeight(asset) && normalizeOwnerAccountId(item.ownerAccountId, currentAccountId()) === owner) || null; } function fnv1a(value) { let hash = 0x811c9dc5; for (let i = 0; i < value.length; i++) { 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 worldIndex.assetById?.get(id) || state.assets.find((asset) => asset.id === id) || null; } function blankPixels(width, height = width) { return Array(Math.max(1, width) * Math.max(1, height)).fill(null); } function normalizePixels(pixels, width, height = width) { const out = blankPixels(width, height); if (typeof pixels === 'string') { for (let i = 0; i < Math.min(out.length, pixels.length); i++) { const ch = pixels[i]; 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, width = null, height = null) { const w = width || Math.sqrt(pixels?.length || 0) || editorSize; const h = height || w; return normalizePixels(pixels, w, h).map((value) => value || '.').join(''); } function colorToHex(value) { if (!value) return 'rgba(0,0,0,0)'; return PALETTE_BY_CODE[value] || value; } function nearestPaletteCode(color) { // This can run during loadState()/seedState() before selectedColorCode is initialized. // Keep the fallback independent from editor state to avoid TDZ startup crashes. return nearestPaletteCodeFrom(PALETTE, color, DEFAULT_SELECTED_COLOR_CODE); } function nearestBasicPaletteCode(color) { return nearestPaletteCodeFrom(PALETTE.slice(0, BASIC_PALETTE_COUNT), PALETTE_BY_CODE[color] || color, PALETTE[0].code); } function nearestPaletteCodeFrom(entries, color, fallback) { if (!color || typeof color !== 'string') return fallback || entries[0]?.code || PALETTE[0].code; if (entries.some((entry) => entry.code === color)) return color; const rgb = parseHex(color); if (!rgb) return fallback || entries[0]?.code || PALETTE[0].code; let best = entries[0]?.code || PALETTE[0].code; let bestDist = Infinity; for (const entry of entries) { 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, width, height = width) { const out = Array(Math.max(1, width) * Math.max(1, height)).fill(0); if (typeof input === 'string') { for (let i = 0; i < Math.min(out.length, input.length); i++) { const ch = input[i]; 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 w = assetWidth(asset); const h = assetHeight(asset); const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], w, h); const sx = asset.category === 'dynamic' && side === 'left' ? w - 1 - x : x; return depth[y * w + sx] || 0; } function applyDepthToColor(hex, depth) { return shadeAssetPixelColor(hex, visualSettings().enableLights === false ? 0 : depth, 0, 0, 1, renderPhase); } function depthLocalLightResponse(distance, reach) { const normalized = clamp(distance / Math.max(0.0001, reach), 0, 1.75); // Keep a broad bright plateau so distance is not the main factor, while // still giving the light source itself a strong flare and a soft tail. const plateau = 1 - Math.pow(Math.min(normalized, 1), 1.12) * 0.2; const shoulder = Math.pow(clamp(1 - normalized / 0.92, 0, 1), 1.08); const hotspot = Math.pow(clamp(1 - normalized / 0.18, 0, 1), 4.1); const tail = Math.pow(clamp(1 - normalized / 1.55, 0, 1), 2.0) * 0.42; return clamp(plateau * 0.72 + shoulder * 0.36 + hotspot * 0.92 + tail, 0, 1.76); } function shadeAssetPixelColor(hex, depth, x, y, width, phase, pixels = null, depths = null, lights = [], height = width) { const rgb = parseHex(hex); if (!rgb) return hex; const activePhase = phase || renderPhase || getPhase(); const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1); const size = Math.max(width, height); const vertical = 1 - (y / Math.max(1, height - 1)); const horizontal = ((x / Math.max(1, width - 1)) - 0.5) * dirX; const solarDepth = (activePhase?.key === 'night' ? 3.0 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 8.5 : 12.0); const depthBoost = depth * solarDepth * 0.35; const exposure = (vertical * 0.62 + horizontal * 0.5) * (activePhase?.key === 'night' ? 3.8 : 10); const edgeBoost = getDepthEdgeLightBoost(depth, x, y, width, pixels, depths, lights, activePhase, height); const selfShadow = getDepthCellSelfShadow(depth, x, y, width, pixels, depths, activePhase, height); let r = rgb.r + depthBoost + exposure + edgeBoost + selfShadow; let g = rgb.g + depthBoost + exposure + edgeBoost + selfShadow; let b = rgb.b + depthBoost + exposure + edgeBoost + selfShadow; let localTintR = 0; let localTintG = 0; let localTintB = 0; let localTintWeight = 0; let localLift = 0; if ((areNightLightsActive(activePhase) || hasCursorInspectionLight(lights)) && lights?.length) { for (const light of lights) { const lc = parseHex(light.c || '#ffd86a'); if (!lc) continue; const isAssetLight = !!light.assetLight; const isCursorLight = !!light.cursor; const dist = Math.hypot((light.x + 0.5) - (x + 0.5), (light.y + 0.5) - (y + 0.5)); const reach = isAssetLight ? Math.max(1.85, size * 0.24) : isCursorLight ? Math.max(2.6, size * 0.52) : Math.max(2.25, size * 0.46); const t = 1 - clamp(dist / reach, 0, 1); const sourceHotspot = Math.pow(clamp(1 - dist / Math.max(0.001, reach * 0.24), 0, 1), 2.8); const assetResponse = clamp(Math.pow(t, 1.45) * 0.52 + sourceHotspot * 1.18, 0, 1.58); const cursorResponse = Math.pow(clamp(t, 0, 1), 1.58) * (0.12 + sourceHotspot * 0.42); const depthResponse = depth ? (isAssetLight ? assetResponse : isCursorLight ? cursorResponse : depthLocalLightResponse(dist, reach)) : (isAssetLight ? assetResponse : t); const response = depth ? depthResponse : t; if (response <= 0) continue; const sourceIntensity = clamp(Number(light.intensity ?? 1) || 1, 0.18, 1.85); const edge = getDepthLightFacing(depth, x, y, width, pixels, depths, light.x, light.y, height); const depthEdgePresence = isAssetLight ? (0.18 + Math.pow(Math.max(0, edge), 1.4) * 0.74 + sourceHotspot * 0.7) : isCursorLight ? (0.02 + edge * 1.12 + sourceHotspot * 0.16) : (0.06 + edge * 1.24); const edgePresence = depth ? depthEdgePresence : 1; const localDepthMultiplier = depth ? DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : 1.1; const strengthBase = depth ? (isAssetLight ? (sourceHotspot * 0.34 + depthResponse * 0.045) : isCursorLight ? (sourceHotspot * 0.18 + depthResponse * 0.12) : (0.12 + depthResponse * 0.22)) : t * t; const strength = strengthBase * (isAssetLight ? (10.5 + sourceHotspot * 18 + Math.abs(depth) * 1.2) : isCursorLight ? (2.1 + Math.abs(depth) * 1.15) : (4.2 + Math.abs(depth) * 2.6)) * edgePresence * sourceIntensity * localDepthMultiplier; const mix = Math.min(depth ? (isAssetLight ? 0.055 : isCursorLight ? 0.18 : 0.48) : (isAssetLight ? 0.06 : isCursorLight ? 0.16 : 0.24), (0.008 + response * (depth ? (isAssetLight ? 0.012 : isCursorLight ? 0.038 : 0.075) : (isAssetLight ? 0.028 : isCursorLight ? 0.072 : 0.10))) * sourceIntensity * (depth ? (isAssetLight ? 0.7 : isCursorLight ? 0.85 : 1.35) : 1)); r = lerp(r, lc.r + strength, mix); g = lerp(g, lc.g + strength * 0.82, mix * 0.94); b = lerp(b, lc.b + strength * 0.74, mix * 0.9); const colorWash = (depth ? (isAssetLight ? 0.004 : isCursorLight ? 0.012 : 0.045) : (isAssetLight ? 0.006 : isCursorLight ? 0.018 : 0.03)) * sourceIntensity * edgePresence * (depth ? (isAssetLight ? sourceHotspot : isCursorLight ? depthResponse : (0.26 + depthResponse * 0.22)) : t); r += (lc.r - 152) * colorWash; g += (lc.g - 152) * colorWash * 0.96; b += (lc.b - 152) * colorWash * 0.92; const tintBase = depth ? (isAssetLight ? sourceHotspot * 0.11 : isCursorLight ? depthResponse : (0.12 + depthResponse * 0.18)) : Math.pow(t, 1.25); const tintPower = tintBase * sourceIntensity * (depth ? (isAssetLight ? 0.016 : isCursorLight ? 0.045 : 0.12) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : (isAssetLight ? 0.012 : isCursorLight ? 0.018 : 0.03)) * (depth ? edgePresence : 1); localTintR += lc.r * tintPower; localTintG += lc.g * tintPower; localTintB += lc.b * tintPower; localTintWeight += tintPower; const liftBase = depth ? (isAssetLight ? (sourceHotspot * 0.42 + Math.pow(t, 2.2) * 0.035) : isCursorLight ? depthResponse : (0.12 + depthResponse * 0.16)) : Math.pow(t, 1.9); localLift += liftBase * sourceIntensity * (depth ? (isAssetLight ? 0.85 : isCursorLight ? 1.05 : 2.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : 3.6) * (depth ? edgePresence : 1); } } const litBeforeQuantization = { r, g, b }; const quantized = quantizePixelShade(rgb, { r, g, b }, activePhase, depth); r = clamp(quantized.r, 0, 255); g = clamp(quantized.g, 0, 255); b = clamp(quantized.b, 0, 255); if (localTintWeight > 0) { const inv = 1 / localTintWeight; const tintMix = clamp(localTintWeight / (depth ? 6.2 : 9.0), 0, depth ? 0.34 : 0.16); const lift = clamp(localLift, 0, depth ? 11 : 12); const chromaRestore = clamp(localTintWeight / (depth ? 1.15 : 8.8), 0, depth ? 0.9 : 0.16); r = lerp(clamp(r + lift, 0, 255), litBeforeQuantization.r, chromaRestore); g = lerp(clamp(g + lift * 0.88, 0, 255), litBeforeQuantization.g, chromaRestore); b = lerp(clamp(b + lift * 0.8, 0, 255), litBeforeQuantization.b, chromaRestore); r = lerp(r, localTintR * inv, tintMix); g = lerp(g, localTintG * inv, tintMix * 0.95); b = lerp(b, localTintB * inv, tintMix * 0.92); } return `#${Math.round(clamp(r, 0, 255)).toString(16).padStart(2,'0')}${Math.round(clamp(g, 0, 255)).toString(16).padStart(2,'0')}${Math.round(clamp(b, 0, 255)).toString(16).padStart(2,'0')}`; } function quantizePixelShade(base, shaded, phase, depth = 0) { // Pixel-art lighting: use 11 discrete bands (-5..+5). The old -2/+2 endpoints // map to the new -5/+5 endpoints, so maximum contrast is preserved. const delta = ((shaded.r + shaded.g + shaded.b) - (base.r + base.g + base.b)) / 3; const nightBias = phase?.key === 'night' ? -1.12 : phase?.key === 'evening' || phase?.key === 'morning' ? -0.38 : 0; let level = Math.round(delta / 7.2); level = clamp(level + (depth > 0 ? 0.45 : depth < 0 ? -0.45 : 0) + nightBias, -5, 5); const amount = [-34, -27, -21, -16, -8, 0, 9, 18, 24, 29, 34][Math.round(level) + 5] || 0; const tintStrength = Math.abs(level) / 5; const tint = phase?.key === 'night' ? { r: -10 * tintStrength, g: -6 * tintStrength, b: 12 * tintStrength } : phase?.key === 'evening' ? { r: 12 * tintStrength, g: 2 * tintStrength, b: -8 * tintStrength } : { r: 0, g: 0, b: 0 }; return { r: base.r + amount + tint.r, g: base.g + amount + tint.g, b: base.b + amount + tint.b }; } function mirrorDepthPixels(depths, width, height = width) { const source = normalizeDepthPixels(depths || [], width, height); const out = Array(width * height).fill(0); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) out[y * width + (width - 1 - x)] = source[y * width + x] || 0; } return out; } function getAssetLightPointsForSide(asset, side = 'right') { const w = assetWidth(asset); const h = assetHeight(asset); const lights = Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels : []; return lights.map((p) => ({ x: asset.category === 'dynamic' && side === 'left' ? w - 1 - p.x : p.x, y: p.y, c: colorToHex(p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a')), assetLight: true, intensity: Number.isFinite(p.intensity) ? p.intensity : 0.92 })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < w && p.y < h); } function isObjectOutlinePixel(pixels, x, y, width, height = width) { if (!pixels || !pixels[y * width + x]) return false; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= width || ny >= height) return true; if (!pixels[ny * width + nx]) return true; } return false; } function collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height = width) { if (!pixels || !depths) return []; const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; const edgeDirs = []; for (const [dx, dy] of dirs) { const nx = x + dx, ny = y + dy; const neighborInside = nx >= 0 && ny >= 0 && nx < width && ny < height; const neighborOpaque = neighborInside && pixels[ny * width + nx]; const neighborDepth = neighborOpaque ? (depths[ny * width + nx] || 0) : 0; if (!neighborOpaque || neighborDepth !== depth) edgeDirs.push([dx, dy]); } return edgeDirs; } function getDepthCornerStrength(edgeDirs) { if (!edgeDirs?.length) return 0; if (edgeDirs.length < 2) return 0; let orthogonalPairs = 0; for (let i = 0; i < edgeDirs.length; i++) { for (let j = i + 1; j < edgeDirs.length; j++) { const [ax, ay] = edgeDirs[i]; const [bx, by] = edgeDirs[j]; if ((ax && by) || (ay && bx)) orthogonalPairs++; } } if (!orthogonalPairs) return edgeDirs.length >= 3 ? 0.6 : 0; return clamp(0.42 + orthogonalPairs * 0.28 + (edgeDirs.length >= 3 ? 0.18 : 0), 0, 1.35); } function getDepthCellSelfShadow(depth, x, y, width, pixels, depths, phase, height = width) { if (!depth || !pixels || !depths) return 0; const edgeDirs = collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height); if (!edgeDirs.length) return depth < 0 ? -5 : 0; const sunX = clamp(-(phase?.shadow?.dirX || -0.45), -1, 1); const sunY = -0.72; let highlight = 0; let shade = 0; for (const [dx, dy] of edgeDirs) { const dot = dx * sunX + dy * sunY; if (dot > 0.34) highlight = Math.max(highlight, dot); if (dot < -0.18) shade = Math.max(shade, -dot); if (dy > 0) shade = Math.max(shade, 0.42); } if (depth > 0) return highlight * 9 - shade * 15; return -8 - shade * 8 + highlight * 3; } function getDepthLightFacing(depth, x, y, width, pixels, depths, lightX, lightY, height = width) { if (!depth || !pixels || !depths) return 0; const edgeDirs = collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height); if (!edgeDirs.length) return 0; const lx = lightX - x; const ly = lightY - y; const len = Math.hypot(lx, ly) || 1; const vx = lx / len, vy = ly / len; let bestDot = -1; for (const [dx, dy] of edgeDirs) bestDot = Math.max(bestDot, dx * vx + dy * vy); return depth > 0 ? Math.max(0, bestDot) : Math.max(0, -bestDot); } function getDepthEdgeLightBoost(depth, x, y, width, pixels, depths, lights, phase, height = width) { if (!depth || !pixels || !depths || !(areNightLightsActive(phase) || hasCursorInspectionLight(lights)) || !lights?.length) return 0; const edgeDirs = collectDepthEdgeDirs(x, y, width, pixels, depths, depth, height); if (!edgeDirs.length) return 0; const outerEdge = edgeDirs.some(([dx, dy]) => { const nx = x + dx, ny = y + dy; return nx < 0 || ny < 0 || nx >= width || ny >= height || !pixels[ny * width + nx]; }); const cornerStrength = getDepthCornerStrength(edgeDirs); const reach = Math.max(2.25, Math.max(width, height) * 0.46); let total = 0; for (const light of lights) { if (!light) continue; const d = Math.hypot(light.x - x, light.y - y); const isCursor = !!light.cursor; const lightReach = isCursor ? Math.max(2.6, Math.max(width, height) * 0.52) : reach; const distanceResponse = isCursor ? Math.pow(clamp(1 - d / lightReach, 0, 1), 1.55) : depthLocalLightResponse(d, lightReach); if (distanceResponse <= 0) continue; const facing = getDepthLightFacing(depth, x, y, width, pixels, depths, light.x, light.y, height); if (!facing) continue; const sourceIntensity = clamp(Number(light.intensity ?? 1) || 1, 0.25, isCursor ? 1.25 : 1.85); const nearSource = Math.pow(clamp(1 - d / Math.max(1.2, lightReach * 0.18), 0, 1), 2.3); const plateau = isCursor ? distanceResponse : 0.74 + distanceResponse * 0.34; const edgeFocus = isCursor ? (0.1 + facing * 1.18) : (0.4 + facing * 0.95); const edgeStrength = outerEdge ? 1.08 : 0.94; const cornerBoost = 1 + cornerStrength * (isCursor ? 0.36 : 0.9 + facing * 0.38); const sourceBoost = 1 + nearSource * (isCursor ? 0.46 : 1.12); const cursorClamp = isCursor ? 0.38 : 1; total += plateau * edgeFocus * edgeStrength * cornerBoost * sourceBoost * sourceIntensity * (depth > 0 ? 8.2 : 6.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER * cursorClamp; } return clamp(total, 0, depth > 0 ? 172 : 126); } 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, width, height = width) { const source = normalizePixels(pixels, width, height); const out = blankPixels(width, height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { out[y * width + (width - 1 - x)] = source[y * width + x] || null; } } return out; } 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() { // 91 one-character palette slots. // 0-j are the compact basic palette shown in normal draw mode. // k-Z plus safe ASCII symbols are advanced slots; storage remains one char per pixel. const basicColors = [ // Ink, paper, and neutral anchors '#111827', '#374151', '#6b7280', '#d1d5db', '#fff7ed', // Common drawing colors arranged from warm to cool '#7f1d1d', '#dc2626', '#f97316', '#facc15', '#84cc16', '#16a34a', '#14b8a6', '#06b6d4', '#2563eb', '#4f46e5', '#7c3aed', '#c026d3', '#ec4899', '#f5d0a9', '#7c4a2d' ]; const advancedColors = [ '#fafafa', '#f5f5f4', '#e7e5e4', '#d6d3d1', '#a8a29e', '#78716c', '#57534e', '#292524', '#0c0a09', '#fef3c7', '#fde68a', '#d6a25f', '#9a6a3a', '#5c3b24', '#fee2e2', '#fca5a5', '#ef4444', '#b91c1c', '#7f1d1d', '#ffedd5', '#fdba74', '#f97316', '#c2410c', '#7c2d12', '#fef9c3', '#fde047', '#eab308', '#a16207', '#713f12', '#ecfccb', '#bef264', '#84cc16', '#4d7c0f', '#365314', '#dcfce7', '#86efac', '#15803d', '#ccfbf1', '#5eead4', '#14b8a6', '#0f766e', '#134e4a', '#cffafe', '#67e8f9', '#22d3ee', '#06b6d4', '#0e7490', '#e0f2fe', '#38bdf8', '#dbeafe', '#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a', '#e0e7ff', '#a5b4fc', '#6366f1', '#4f46e5', '#3730a3', '#ede9fe', '#c4b5fd', '#8b5cf6', '#fae8ff', '#e879f9', '#c026d3', '#86198f', '#fce7f3', '#f9a8d4', '#ec4899', '#be185d' ]; const colors = [...basicColors, ...advancedColors]; return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] || hslToHex((index * 47) % 360, 72, 58) })); } 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 artRows(rows, legend) { return artRowsSized(rows, legend, 16, 16); } function artRowsSized(rows, legend, width, height = width) { const w = clampDimension(width, 16); const h = clampDimension(height, w); const out = blankPixels(w, h); for (let y = 0; y < h; y++) { const row = String(rows[y] || '').padEnd(w, '.').slice(0, w); for (let x = 0; x < w; x++) { const value = legend[row[x]]; if (value) out[y * w + x] = value; } } return out; } function depthRows(rows) { return depthRowsSized(rows, 16, 16); } function depthRowsSized(rows, width, height = width) { const w = clampDimension(width, 16); const h = clampDimension(height, w); const out = Array(w * h).fill(0); for (let y = 0; y < h; y++) { const row = String(rows[y] || '').padEnd(w, '.').slice(0, w); for (let x = 0; x < w; x++) { const ch = row[x]; if (ch === '^' || ch === '+') out[y * w + x] = 1; if (ch === 'v' || ch === '-') out[y * w + x] = -1; } } return out; } function drawCrescentTeaHouse() { return artRows([ '.......YY.......', '......YWWY......', '.....YWWY.......', '......YY........', '....NNNNNNN.....', '...NMMMMMMMN....', '..NMMYMMYMMN....', '..MMMWMMWMMM....', '..MMMMMMMMMM....', '..MMMYDDYMMM....', '..MMMDYYDMMM....', '..MMMDYYDMMM....', '...MMDDDDMM.....', '....NNNNNN......', '....MMDDMM......', '...NNNNNNNN.....' ], { Y: '#ffe55d', W: '#fffdf7', N: '#31333d', M: '#95806b', D: '#222632' }); } function drawMoonJellyAquarium() { return artRowsSized([ '...........YY...........', '..........YCCY..........', '.........YNCCNY.........', '........NQBBBBQN........', '.......NQBAAAABQN.......', '......NQBAACCAABQN......', '......NBAAAAAAAABN......', '.....NQBAAPPPPAABQN.....', '.....NBAAPPYYPPAABN.....', '....NQBAAPYYYYPPAABQN...', '....NBAAPYYYYYYPPAABN...', '....NBAAPYYYYYYPPAABN...', '...NQBAAPYYYYYYPPAABQN..', '...N.BAAPQQQQQQPPAAB.N..', '...N.NYYQNNNNNNQYYN.N...', '...N.TSYQY....YQYST.N...', '....NTTSY......YSTTN....', '....NSSSY......YSSSN....', '....NSSSY..DD..YSSSN....', '....NSSSY.DDDD.YSSSN....', '....NSSSYYYYYYYYSSSN....', '....NSSSTTTTTTTTSSSN....', '....NTTTTSDDDDSTTTTN....', '.....NTTTSD...DSTTTN....', '.....NDDDSS...SSDDDN....', '......NDDDDNNNDDDDN.....', '.......NNNNNNNNNNN......', '........................' ], { N: '#1e2030', Y: '#ffe8a1', C: '#f7ffff', Q: '#9b7dff', B: '#6dd8f0', A: '#a6f5ff', P: '#ff8cc8', T: '#5f5968', S: '#8a7f91', D: '#3c3348' }, 24, 28); } function drawMoonJellyAquariumDepth() { return depthRowsSized([ '...........^^...........', '..........^^^^..........', '.........^^^^^^.........', '........^^^^^^^^........', '.......^^^^^^^^^^.......', '......^^^^^^^^^^^^......', '......^^^^^^^^^^^^......', '.....^^^^^^..^^^^^^.....', '.....^^^^^....^^^^^.....', '....^^^^^^....^^^^^^....', '....^^^^^^....^^^^^^....', '....^^^^^^....^^^^^^....', '...^^^^^^^....^^^^^^^...', '...^^^^^^......^^^^^^...', '...^^..^^......^^..^^...', '...^^..^^......^^..^^...', '....^^..^......^..^^....', '....^^..^......^..^^....', '....^^^^^..--..^^^^^....', '....^^^^^.----.^^^^^....', '....^^^^^^^^^^^^^^^^....', '....^^^^^------^^^^^....', '....^^^^^------^^^^^....', '.....^^^^------^^^^.....', '.....^^^^^....^^^^^.....', '......^^^^^..^^^^^......', '.......^^^^^^^^^^.......', '........................' ], 24, 28); } function drawCrescentTeaHouseDepth() { return depthRows([ '.......^^.......', '......^^^^......', '.....^^^^.......', '......^^........', '....-------.....', '...-^^^^^^^-....', '..-^^^^^^^^-....', '..-^^^^^^^^-....', '..-^^^^^^^^-....', '..-^^^--^^^-....', '..-^^^--^^^-....', '..-^^^--^^^-....', '...--------.....', '....------......', '....--^^--......', '...--------.....' ]); } function drawPrismSakura() { return artRows([ '................', '......PP.P......', '....PPRRPPP.....', '...PRRPPPRP.....', '..PPPRPPRPPP....', '...PPRRRPP......', '.....PRP........', '......T.........', '.....GTG........', '....GTTTG.......', '...GGTTTGG......', '.....TTT........', '.....TNT........', '....NNNNN.......', '...NNNNNNN......', '................' ], { P: '#ffb0c7', R: '#ef6b88', T: '#75614f', G: '#57cf78', N: '#454048' }); } function drawPrismSakuraDepth() { return depthRows([ '................', '......^^.^......', '....^^^^^^^.....', '...^^^^^^^^.....', '..^^^^^^^^^^....', '...^^^^^^^......', '.....^^^........', '......-.........', '.....---........', '....-----.......', '...-------......', '.....---........', '.....---........', '....-----.......', '...-------......', '................' ]); } function drawClockworkWhale() { return artRows([ '................', '................', '.......Y........', '......YY........', '...BBQQQQQB.....', '..BQQQQQQQQB....', '.BQQQWQQQWQB....', '.BQQQQQQQQQB.Y..', '..BQQQQQQQB.YY..', '...BBBBBBB.YY...', '....NNNNNN......', '.....NNNN.......', '................', '................', '................', '................' ], { B: '#1f7fa2', Q: '#60d6e8', W: '#fffdf7', Y: '#ffe55d', N: '#5a4d43' }); } function drawClockworkWhaleDepth() { return depthRows([ '................', '................', '.......^........', '......^^........', '...^^^^^^^^.....', '..^^^^^^^^^^....', '.^^^^^^^^^^^....', '.^^^^^^^^^^^.^..', '..^^^^^^^^^.^^..', '...-------.^^...', '....------......', '.....----.......', '................', '................', '................', '................' ]); } function drawLanternCatRight() { return artRows([ '................', '................', '.....N..N.......', '....NSSSSN......', '....SWSWS.......', '....SSKSS.......', '...OOOOOOO......', '..OOOYYOOOY.....', '..OOOOOOOYY.....', '...O..OOO.......', '..NN..N.N.......', '................', '................', '................', '................', '................' ], { N: '#151923', S: '#ffc071', W: '#fffdf7', K: '#31333d', O: '#ee9a55', Y: '#ffe55d' }); } function drawCloudKoiRight() { return artRows([ '................', '................', '................', '.....C..........', '....CCC.........', '...CQQQCCC......', '..CQQQQQQQC.....', '.CQQWQQQWQQC....', '..CQQQQQQQC..P..', '...CQQQCCC..PP..', '.....C......P...', '................', '................', '................', '................', '................' ], { C: '#aef8ff', Q: '#60d6e8', W: '#fffdf7', P: '#ffb0c7' }); } function drawPaperCraneRight() { return artRows([ '................', '................', '.......W........', '......WWW.......', '.....WWCWW......', '....WWCCWWW.....', '...WWCCNCCWW....', '.....WCNW.......', '......NN........', '.....N..N.......', '....N....N......', '................', '................', '................', '................', '................' ], { W: '#eef4ff', C: '#cbdcff', N: '#151923' }); } function drawMoonLantern() { return artRows([ '.......Y........', '......YYY.......', '......YWY.......', '.....YYYYY......', '....NNNNNNN.....', '...NMMMMMMMN....', '...MBBMMBBM.....', '..NMMYYYYMMN....', '..MMMMYYMMMM....', '..MMMDYYDMMM....', '..MMMDDDDMMM....', '..MMMRDDRRMM....', '...MMRDDRM......', '...MMMMMMMM.....', '....MMMMMM......', '....NNNNNN......' ], { Y: '#ffe99a', W: '#fffdf7', N: '#3f3e42', M: '#806f5f', B: '#8aa7ef', D: '#191a22', R: '#de4d6d' }); } function drawMoonLanternDepth() { return depthRows([ '.......^........', '......^^^.......', '......^^^.......', '.....^^^^^......', '....-------.....', '...-^^^^^^^-....', '...-^^^^^^-.....', '..-^^^^^^^^-....', '..-^^^^^^^^-....', '..-^^^--^^^-....', '..-^^^--^^^-....', '..-^^^--^^^-....', '...-^^^^^^-.....', '...--------.....', '....------......', '....------......' ]); } function drawGlassFern() { return artRows([ '................', '.......G........', '......GEG.......', '.....GEEEG......', '..C..GEGEG..C...', '...C.GEGEG.C....', '....CGEEEGC.....', '.....GEGEG......', '....GEEEEE......', '...GEEEGEG......', '..GEGEGEGEG.....', '....TTTTT.......', '....TNNNT.......', '...TTNNNTT......', '...NNNNNNN......', '................' ], { G: '#45b96b', E: '#aaf0ce', C: '#bff7ff', T: '#674d0b', N: '#806f5f' }); } function drawStarTotem() { return artRows([ '.......Y........', '......YYY.......', '.......Y........', '.....PYPYP......', '....PPPPPPP.....', '.....PBPBP......', '......BBB.......', '.....BNNNB......', '.....NNYNN......', '.....NNYNN......', '....NNNYNNN.....', '....NNNNNNN.....', '.....NNNNN......', '....MMMMMMM.....', '...MMMMMMMMM....', '................' ], { Y: '#ffe99a', P: '#c79bed', B: '#8aa7ef', N: '#5e554e', M: '#3f3e42' }); } function drawStarTotemDepth() { return depthRows([ '.......^........', '......^^^.......', '.......^........', '.....^^^^^......', '....^^^^^^^.....', '.....^^^^^......', '......---.......', '.....-^^^-......', '.....-^^^-......', '.....-^^^-......', '....-^^^^^-.....', '....-------.....', '.....-----......', '....-------.....', '...---------....', '................' ]); } function drawCoralSkiff() { return artRows([ '................', '........N.......', '........N.......', '.......WYN......', '......WWYN......', '.....WWYYN......', '....WYYYYN......', '.......NNN......', '...R...NN...R...', '..RRRMMMMMMRR...', '..RMMMMMMMMMR...', '...MMMMMMMM.....', '....DDDDDD......', '.....DDDD.......', '................', '................' ], { N: '#5e554e', W: '#fffdf7', Y: '#ffe99a', R: '#fa6e5a', M: '#a95a32', D: '#3d2218' }); } function drawCoralSkiffDepth() { return depthRows([ '................', '........^.......', '........^.......', '.......^^.......', '......^^^.......', '.....^^^^.......', '....^^^^^.......', '.......---......', '...^...--...^...', '..^^^-------^...', '..-----------...', '...--------.....', '....------......', '.....----.......', '................', '................' ]); } function drawLanternWalkerRight() { return artRows([ '................', '................', '......NNN.......', '.....NSSSN......', '.....NSKSN......', '......SSS.......', '.....RRRRR......', '....RRRRYRY.....', '....RRRR.YY.....', '.....BBBR.......', '.....B.BB.......', '.....B..B.......', '....NN..NN......', '................', '................', '................' ], { N: '#191a22', S: '#ffd59c', K: '#3f3e42', R: '#de4d6d', Y: '#ffe99a', B: '#354ca3' }); } function drawSproutFoxRight() { return artRows([ '................', '................', '................', '.........G......', '........GEG.....', '....OOO..G......', '...OWWOO........', '..OOOWWOOO......', '..ODODDOWOO.....', '..OOOOOOO.......', '...O..O.O.......', '..NN..NNN.......', '................', '................', '................', '................' ], { O: '#ffad63', W: '#fffdf7', D: '#a95a32', N: '#3d2218', G: '#45b96b', E: '#aaf0ce' }); } function drawAzureMinnowRight() { return artRows([ '................', '................', '................', '................', '................', '.....CQQQC......', '...CCQQQQQC.....', '..CQQQWBQQQCC...', '...CCQQQQQC.....', '.....CQQQC......', '.......C........', '................', '................', '................', '................', '................' ], { C: '#bff7ff', Q: '#35b9d0', W: '#fffdf7', B: '#141942' }); } function drawVioletMothRight() { return artRows([ '................', '................', '.......P........', '.....PPBPP......', '....PPBKBPP.....', '...PPBBKBBPP....', '.....PBKBP......', '......NKN.......', '.....I.N.I......', '....I.....I.....', '................', '................', '................', '................', '................', '................' ], { P: '#c79bed', B: '#9e65d8', K: '#4b206f', N: '#191a22', I: '#ead1ff' }); } function drawWildflowerPatch() { return artRowsSized([ '........', '.gPgYg..', 'gWgBgPg.', 'ggggggg.', 'gYgPgWg.', 'ggggggg.', '.GgGgG..', '..NN....' ], { g: '#69c96f', G: '#3f9a46', P: '#f38db6', Y: '#f2dd59', W: '#fffdf7', B: '#77d7ff', N: '#7e5a3e' }, 8, 8); } function drawRiverStones() { return artRowsSized([ '........', '..bb....', '.bssb...', 'bsmmsb..', '.bssssb.', '..bgGb..', '...bb...', '........' ], { b: '#7ba9cf', s: '#c7cdd1', m: '#8d959c', g: '#86d06c', G: '#4d9d45' }, 8, 8); } function drawMapleCanopy() { return artRows([ '......OO........', '....OORROO......', '...OORRYYOO.....', '..OORRYYYYOO....', '..ORRYYGGYYO....', '.ORRYYGGGGYYO...', '.ORYYGGGGGGYO...', '.OYYGGWWGGGYO...', '..OYYGGGGGGYO...', '..OOYGGGGYYO....', '...OOYYYYYO.....', '.....TTTT.......', '....TTTTTT......', '....TTTTTT......', '...NNNNNNNN.....', '................' ], { O: '#f08c4a', R: '#de4d5d', Y: '#f2d255', G: '#6fbf5e', W: '#fff1d6', T: '#7a4f2c', N: '#4f3828' }); } function drawMapleCanopyDepth() { return depthRows([ '......^^........', '....^^^^^^......', '...^^^^^^^^.....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '.^^^^^^^^^^^^...', '.^^^^^^^^^^^^...', '.^^^^^^^^^^^^...', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '...^^^^^^^^.....', '.....----.......', '....------......', '....------......', '...--------.....', '................' ]); } function drawMistyFalls() { return artRows([ '....GGGGGG......', '...GSSSSSSG.....', '..GSSCCCCSSG....', '..GSCWWWWCSG....', '..GSCWBBWCSG....', '..GSCWBBWCSG....', '..GSCWBBWCSG....', '..GSCWBBWCSG....', '..GSCWWWWCSG....', '...GCWWWWCG.....', '....CCWWCC......', '....WWWWWW......', '...WTTTTTTW.....', '..WTTTTTTTTW....', '..WWWWWWWWWW....', '................' ], { G: '#63b867', S: '#747f88', C: '#b7f2ff', W: '#e8fbff', B: '#63c8ef', T: '#7bb7d6' }); } function drawMistyFallsDepth() { return depthRows([ '....^^^^^^......', '...^^^^^^^^.....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '..^^^^^^^^^^....', '...^^^^^^^^.....', '....^^^^^^......', '....------......', '...--------.....', '..----------....', '..----------....', '................' ]); } function drawLotusPond() { return artRowsSized([ '................', '....gG....gG....', '..gGGGg..gGGg...', '..GGPPGGGGPWG...', '.GGPPWPPGGPPGG..', '.GGBBBBBBBBBBG..', '.GBBBBBBBBBBBG..', '.GGBBBBBBBBBGG..', '..GGGBBBBGGG....', '...GGGGGGGG.....', '....n....n......', '................' ], { g: '#7dd27c', G: '#4ea759', P: '#f59bc4', W: '#fff8f3', B: '#5bc3e7', n: '#56713d' }, 16, 12); } function drawLotusPondDepth() { return depthRowsSized([ '................', '....^^....^^....', '..^^^^^..^^^^...', '..^^^^^^^^^^^...', '.^^^^^^^^^^^^^..', '.^^^^vvvv^^^^^..', '.^^^vvvvvvv^^^..', '.^^^^vvvv^^^^^..', '..^^^^^^^^^^....', '...^^^^^^^^.....', '....-....-......', '................' ], 16, 12); } function drawMossyArch() { return artRows([ '................', '.....GGGG.......', '....GSSSSG......', '...GSSSSSSG.....', '..GSSGGGGSSG....', '..GSG....GSG....', '..GSG....GSG....', '..GSG....GSG....', '..GSG....GSG....', '..GSG....GSG....', '..GSSGGGGSSG....', '..GGGLLLLGGG....', '....LLLLLL......', '...LLL..LLL.....', '..NNNN..NNNN....', '................' ], { G: '#58b96c', S: '#8e8f92', L: '#c1d57f', N: '#556046' }); } function drawMossyArchDepth() { return depthRows([ '................', '.....^^^^.......', '....^^^^^^......', '...^^^^^^^^.....', '..^^^^..^^^^....', '..^^^....^^^....', '..^^^....^^^....', '..^^^....^^^....', '..^^^....^^^....', '..^^^....^^^....', '..^^^^..^^^^....', '..^^^^^^^^^^....', '....------......', '...---..---.....', '..----..----....', '................' ]); } function drawSunflowerGrove() { return artRowsSized([ '............', '.yy..yy..yy.', 'yOOy.OOy.yOO', 'yOWOyOWOyOWO', '.yOO..OO..OO', '..GG..GG..GG', '..GgGGGgGGgG', '.gGGGGGGGGGg', '.gGgGgGgGgGg', '..G..G..G..G', '..T..T..T..T', '..T..T..T..T', '.TTTTTTTTTT.', '.GgGgGgGgGg.', '..NNNNNNNN..', '............' ], { y: '#f6df56', O: '#f09a3c', W: '#6f4b2a', G: '#64bd62', g: '#8fe17c', T: '#6b8f3f', N: '#7b5a41' }, 12, 16); } function drawReedBed() { return artRowsSized([ '............', '....rrr.....', '..r.rrR.r...', '..RrrrRrR...', '..RrrrRrr...', '..RrrrRrr...', '.wwwwwccww..', 'wcccccbbccw.', 'wcbbbbbbbcw.', '.wwccccccw..', '..GGGGGG....', '...NNNN.....' ], { r: '#b8d96f', R: '#7fb04a', w: '#d8f5ff', c: '#7bd1e6', b: '#4ca7d3', G: '#67bf68', N: '#7b6347' }, 12, 12); } function drawFireflySwirlRight() { return artRowsSized([ '........', '...Y....', '..YGY...', '.YGWGY..', '..YGY...', '...Y....', '..S.S...', '........' ], { Y: '#fff08a', G: '#7dcf56', W: '#fffdf7', S: '#6bb2ff' }, 8, 8); } function drawMeadowHareRight() { return artRowsSized([ '............', '....EE......', '...EFFE.....', '...EFFFE....', '..EFFWWFE...', '..EFFGGFE...', '..EFFFFFE...', '..EFFFFF....', '...F.FF.....', '..NN.N.N....', '............', '............' ], { E: '#d8b08a', F: '#c79267', W: '#fff4ec', G: '#5a4333', N: '#6a574d' }, 12, 12); } function drawBrookTurtleRight() { return artRowsSized([ '............', '............', '....GGG.....', '..GYYYYG....', '.GYYWWYYG...', '.GYYGGYYGG..', '..GYYYYYG...', '...GGGG..B..', '....N..NBB..', '............' ], { G: '#5ebc68', Y: '#a3d56c', W: '#fff5d8', B: '#87dfff', N: '#516046' }, 12, 10); } function drawLeafSparrowRight() { return artRowsSized([ '........', '...G....', '..GGW...', '.GWWWW..', '.WWCCW..', '..WCCG..', '...N.N..', '........' ], { G: '#70bf5d', W: '#eef9ec', C: '#8edc9e', N: '#5b4e43' }, 8, 8); } function drawRedPandaRight() { return artRowsSized([ '............', '...EE..EE...', '..EOEOEOOE..', '..EOOWWOOE..', '..EOOGGOOE..', '...ORRRRO...', '..ORRRRRRO..', '..ORRWWRRO..', '..ORRRRRRO..', '...R..R.R...', '..N...N.N...', '............' ], { E: '#f4efe9', O: '#d87436', W: '#fff7ef', G: '#4b3428', R: '#c95f2c', N: '#7b624e' }, 12, 12); } function drawRiverOtterRight() { return artRowsSized([ '..............', '...BBB........', '..BCCCCBB.....', '.BCCWWCCCBB...', '.BCCGGCCCCCB..', '.BCCCCCCCCCB..', '..BCCCC..CC...', '...NNN...N....' ], { B: '#7a5b44', C: '#9b7658', W: '#efe1d2', G: '#3a2b24', N: '#5c534f' }, 14, 8); } function drawAmberDeerRight() { return artRowsSized([ '..............', '.....A........', '....A.A.......', '...AAWAA......', '...AWWWAA.....', '..AAWGGWAA....', '..AWWWWWWA....', '..AWWWWWWAA...', '..AWWWWWWWA...', '...AWWWWWA....', '...A.AA.A.....', '..N..AA..N.....', '..N..AA..N.....', '..............' ], { A: '#d5954c', W: '#efc27d', G: '#3f2c21', N: '#71584a' }, 14, 14); } function drawForestOwlRight() { return artRowsSized([ '..........', '...EE.E...', '..EBBBBE..', '..BWWWWB..', '.BWWGGWWB.', '.BWWWWWWB.', '.BBWWWWBB.', '..BYYYYB..', '..BYYYYB..', '..N.BB.N..', '...N..N...', '..........' ], { E: '#c9a05b', B: '#7b5d40', W: '#f3e9d7', G: '#46352a', Y: '#b99658', N: '#6c5b4b' }, 10, 12); } function drawKingfisherRight() { return artRowsSized([ '..........', '....C.....', '...CCCW...', '..CBWWWWY.', '.CCBWWGGY.', '..CBBBBY..', '....N.N...', '..........' ], { C: '#2b91c9', B: '#1d5d8e', W: '#f8f5ed', G: '#44403b', Y: '#e6a74c', N: '#5a514a' }, 10, 8); } function drawPondDuckRight() { return artRowsSized([ '............', '............', '....GG......', '...GWWWW....', '..GWWWGGGY...', '..GWWWWWWWY..', '..GGGWWWWY...', '....W..W.....', '...N....N....', '............' ], { G: '#4f8f46', W: '#efe6d8', Y: '#e7aa55', N: '#6a5e4f' }, 12, 10); } function drawHeronRight() { return artRowsSized([ '..........', '.....W....', '....WWW...', '.....WWY..', '....WWWW..', '...WWWGG..', '...WWWWW..', '....WWWW..', '.....WW...', '.....WW...', '.....WW...', '.....WW...', '....N..N..', '....N..N..', '..........', '..........' ], { W: '#f4f6f7', Y: '#d5b073', G: '#49525e', N: '#756b5f' }, 10, 16); } function drawSunsetKoiRight() { return artRowsSized([ '............', '............', '....O.......', '..OORRRROO..', '.ORRWWWRRRY.', '..ORRRRRROO.', '....O.O.....', '............' ], { O: '#f39a44', R: '#ef635b', W: '#fff6ea', Y: '#f4c14a' }, 12, 8); } function drawSilverTroutRight() { return artRowsSized([ '............', '....S.......', '..SSCCCSS...', '.SCCWWCCCB..', '..SSCCCSS...', '....S.......' ], { S: '#8aa6bf', C: '#c6d6e3', W: '#f5fbff', B: '#3f596b' }, 12, 6); } function drawButterflyFishRight() { return artRowsSized([ '..........', '....Y.....', '..YYWWYY..', '.YWWBBWYY.', '..YYWWYYK.', '....Y.....', '..........', '..........' ], { Y: '#f3d454', W: '#fff7e6', B: '#263347', K: '#f09a42' }, 10, 8); } function drawTownGardenerRight() { return artRowsSized([ '............', '....NN......', '...NSSN.....', '...SWWS.....', '...SSGSS....', '....GRR.....', '...GRRRR....', '...GRRRR....', '....BBB.....', '...BB.BB....', '...B...B....', '..NN...NN...', '............', '............', '............', '............' ], { N: '#27252b', S: '#f0c099', W: '#fff6ef', G: '#4b6f3d', R: '#89c96a', B: '#6e4f3a' }, 12, 16); } function drawLanternCourierRight() { return artRowsSized([ '............', '....NN......', '...NSSN.....', '...SWWS.....', '...SSSS.....', '....RRRR....', '...RRRRRY...', '...RRRR.Y...', '....BBBB....', '...BB.BB....', '...B...B....', '..NN...NN...', '............', '............', '............', '............' ], { N: '#232531', S: '#f2c7a3', W: '#fff7ef', R: '#c85252', Y: '#ffe99a', B: '#3f5078' }, 12, 16); } function drawPlazaMusicianRight() { return artRowsSized([ '..............', '.....NN.......', '....NSSN......', '....SWWS......', '....SSSS......', '...PPPPPP.....', '...PBBQQQ.....', '...PBBQQQ.....', '....QTTQ......', '...QT..TQ.....', '...N....N.....', '..NN....NN....', '..............', '..............', '..............', '..............' ], { N: '#2b2530', S: '#f0c39b', W: '#fff7ee', P: '#8a5ab1', B: '#5a3f26', Q: '#d8a45a', T: '#6e5749' }, 14, 16); } function drawBridgeMechanicRight() { return artRowsSized([ '..............', '.....NN.......', '....NSSN......', '....SWWS......', '....SSSS......', '...CCCCCC.....', '...CCKCCC.....', '...CCKCCY.....', '....BBBB......', '...BB.BB......', '...B...B......', '..NN...NN.....', '..............', '..............', '..............', '..............' ], { N: '#272934', S: '#efc39e', W: '#fff8f1', C: '#4f8ca8', K: '#293645', Y: '#e8b65d', B: '#6b5241' }, 14, 16); } function drawHarborClocktower() { const w = 32, h = 40, p = blankPixels(w, h); rect(p, w, 12, 6, 8, 26, '#b08867'); rect(p, w, 11, 30, 10, 7, '#9a7658'); rect(p, w, 10, 35, 12, 3, '#6f5746'); rect(p, w, 13, 1, 6, 5, '#8f5c4d'); for (let y = 0; y < 3; y++) for (let x = 13 - y; x <= 18 + y; x++) px(p, w, x, y, y === 0 ? '#6d4650' : '#835764'); rect(p, w, 13, 10, 6, 6, '#f0eadb'); rect(p, w, 14, 11, 4, 4, '#fffdf7'); px(p, w, 15, 13, '#4f535f'); px(p, w, 16, 13, '#4f535f'); px(p, w, 16, 12, '#4f535f'); rect(p, w, 13, 19, 2, 3, '#ffe99a'); rect(p, w, 17, 19, 2, 3, '#ffe99a'); rect(p, w, 14, 25, 2, 3, '#ffe99a'); rect(p, w, 17, 25, 2, 3, '#ffe99a'); rect(p, w, 14, 32, 4, 5, '#3b3348'); rect(p, w, 8, 37, 16, 2, '#544539'); return p; } function drawGlassGreenhouse() { const w = 28, h = 20, p = blankPixels(w, h); for (let y = 2; y <= 6; y++) { const inset = 10 - y; for (let x = 6 + inset; x < w - 6 - inset; x++) px(p, w, x, y, y % 2 ? '#dff7ff' : '#b7ecff'); } rect(p, w, 4, 7, 20, 10, '#dff7ff'); for (let x = 4; x <= 23; x += 4) rect(p, w, x, 7, 1, 10, '#7aa6a0'); for (let y = 7; y <= 16; y += 3) rect(p, w, 4, y, 20, 1, '#7aa6a0'); rect(p, w, 12, 12, 4, 5, '#fff4c2'); rect(p, w, 11, 17, 6, 2, '#6d5442'); rect(p, w, 7, 14, 3, 2, '#6fc26b'); rect(p, w, 18, 14, 3, 2, '#6fc26b'); rect(p, w, 8, 12, 2, 2, '#8adf73'); rect(p, w, 18, 11, 2, 2, '#8adf73'); return p; } function drawSteamWorkshop() { const w = 40, h = 24, p = blankPixels(w, h); rect(p, w, 4, 8, 32, 12, '#8b6a58'); rect(p, w, 6, 10, 10, 8, '#a27d66'); rect(p, w, 18, 10, 16, 8, '#9a735d'); rect(p, w, 6, 6, 13, 2, '#5f4d43'); rect(p, w, 18, 5, 17, 3, '#4d4e55'); rect(p, w, 9, 11, 3, 3, '#ffd979'); rect(p, w, 14, 11, 3, 3, '#ffd979'); rect(p, w, 24, 11, 3, 3, '#ffd979'); rect(p, w, 29, 11, 3, 3, '#ffd979'); rect(p, w, 18, 15, 6, 5, '#3d3340'); rect(p, w, 10, 0, 4, 8, '#6d5548'); rect(p, w, 27, 0, 5, 8, '#5b5b62'); rect(p, w, 11, 0, 2, 3, '#9fc8d4'); rect(p, w, 28, 0, 3, 3, '#9fc8d4'); rect(p, w, 2, 20, 36, 3, '#5b4940'); return p; } function drawCanalBridge() { const w = 48, h = 16, p = blankPixels(w, h); rect(p, w, 3, 12, 42, 2, '#6a5446'); rect(p, w, 6, 10, 36, 2, '#8e715d'); rect(p, w, 8, 8, 32, 2, '#b08c72'); for (let i = 0; i < 8; i++) { rect(p, w, 10 + i * 4, 5, 1, 5, '#d9c4a8'); rect(p, w, 11 + i * 4, 4, 2, 1, '#d9c4a8'); } for (let y = 10; y <= 13; y++) { const inset = Math.abs(11 - y) * 2; rect(p, w, 16 + inset, y, 16 - inset * 2, 1, '#3a78a6'); } return p; } function drawGrandFountain() { return artRowsSized([ '........................', '...........WW...........', '..........WWWW..........', '.........WBBBBW.........', '..........WBBW..........', '.........WWBBWW.........', '........WBBBBBBW........', '.........WBBBBW.........', '.......SSSWWWWSSS.......', '......SCCCSSSSCCCS......', '.....SCCBBBBBBCCCS......', '....SCCBBBBBBBBCCCS.....', '....SCBBBBWWBBBBCCS.....', '....SCCBBBBBBBBCCCS.....', '.....SCCBBBBBBCCCS......', '......SCCCSSSSCCS.......', '.......SSSSSSSSS........', '.......NNNNNNNNN........', '......NNNSSSSNNNN.......', '.....NNSSSSSSSSNN.......', '....NNNNNNNNNNNNNN......', '....NNBBBBBBBBBBNN......', '.....NNNNNNNNNNNN.......', '........................' ], { W: '#eafcff', B: '#7ed3ef', C: '#bff2ff', S: '#9b8b7d', N: '#6f6258' }, 24, 24); } function drawRocketMonument() { return artRowsSized([ '.........RR.........', '........RWWR........', '.......RWWWWR.......', '.......RWWWWR.......', '......RRWGGWRR......', '......RWWGGWWR......', '......RWWWWWWR......', '......RWWBBWWR......', '......RWWBBWWR......', '......RWWWWWWR......', '.....RRWWWWWWRR.....', '.....RWWWWWWWWR.....', '.....RWWWWWWWWR.....', '.....RWWRRWWWWR.....', '.....RWWRRWWWWR.....', '.....RWWWWWWWWR.....', '.....RRWWWWWWRR.....', '......RWWWWWWR......', '.....RRWWWWWWRR.....', '....RRRWWWWWWRRR....', '....RWWWWWWWWWWR....', '....RWWWWWWWWWWR....', '....RRRRWWWWRRRR....', '.....BBBWWWWBBB.....', '.....BBYYYYYYBB.....', '.....BBOOYYOOBB.....', '.....BBYYYYYYBB.....', '.....BBBBBBBBBB.....', '......NNN..NNN......', '.....NNN....NNN.....', '.....NN......NN.....', '....................' ], { R: '#d95f5f', W: '#f3f4f8', G: '#86d9ff', B: '#776154', Y: '#ffd979', O: '#ff8a5c', N: '#514740' }, 20, 32); } function drawArcadeBooth() { const w = 24, h = 20, p = blankPixels(w,h); rect(p,w,3,5,18,11,'#5a3d70'); rect(p,w,4,3,16,3,'#8d5cc2'); rect(p,w,6,7,12,6,'#161c2f'); rect(p,w,7,8,10,4,'#2bb4ff'); rect(p,w,9,14,6,2,'#3b3348'); rect(p,w,8,16,8,2,'#6f5142'); px(p,w,10,4,'#ffe66a'); px(p,w,13,4,'#ff8cc8'); px(p,w,16,4,'#8ef2ff'); rect(p,w,2,18,20,1,'#47352b'); return p; } function drawChessKnightStatue() { return artRowsSized([ '................', '......GG........', '.....GWWG.......', '....GWWWWG......', '....GWWBWWG.....', '....GWWWWGG.....', '...GWWWWWWG.....', '...GWWGWWWG.....', '...GWWGGWWG.....', '....GWWWWG......', '....GWWWWG......', '...GGWWWWGG.....', '..GGGWWWWGGG....', '..GWWWWWWWWG....', '..GGGGWWGGGG....', '....NNWWNN......', '...NNNWWNNN.....', '..NNNNNNNNNN....', '..NNNSSSSNNN....', '..NNSSSSSSNN....', '.NNNNNNNNNNNN...', '.NBBBBBBBBBBN...', '..NNNNNNNNNN....', '................' ], { G: '#c8cfda', W: '#f8fbff', B: '#8fa1bd', N: '#706257', S: '#9e9184' }, 16, 24); } function drawDesertTrain() { const w = 44, h = 16, p = blankPixels(w, h); rect(p, w, 4, 8, 7, 4, '#8b6b58'); rect(p, w, 11, 7, 13, 5, '#b27d4c'); rect(p, w, 24, 7, 13, 5, '#c58a54'); rect(p, w, 37, 8, 5, 4, '#915f40'); rect(p, w, 5, 5, 4, 3, '#645a5f'); rect(p, w, 12, 5, 10, 2, '#744f45'); rect(p, w, 25, 5, 10, 2, '#744f45'); rect(p, w, 13, 8, 3, 2, '#ffd979'); rect(p, w, 18, 8, 3, 2, '#ffd979'); rect(p, w, 27, 8, 3, 2, '#ffd979'); rect(p, w, 32, 8, 3, 2, '#ffd979'); rect(p, w, 7, 7, 2, 2, '#ffe7a4'); rect(p, w, 6, 3, 2, 2, '#d9e4e8'); rect(p, w, 8, 2, 2, 1, '#eef4f7'); rect(p, w, 11, 12, 24, 1, '#684f41'); for (const x of [6, 15, 21, 29, 35, 40]) { circle(p, w, x, 13, 2, '#302a2a'); px(p, w, x, 13, '#8f857b'); } rect(p, w, 0, 15, 44, 1, '#5f4a3f'); rect(p, w, 2, 14, 40, 1, '#b98d5d'); return p; } function drawTeaRobotRight() { return artRowsSized([ '............', '.....CC.....', '....CWWC....', '....CWGWC...', '....CWWWC...', '.....MMM....', '....MKKKM...', '....MKYKMY..', '.....MKKM...', '....B.BBB...', '...BB..BB...', '...N....N...', '............', '............', '............', '............' ], {C:'#8fd3e8',W:'#f5fbff',G:'#4b525d',M:'#c38f61',K:'#6d584b',Y:'#9be6ff',B:'#86736b',N:'#4d4c55'}, 12, 16); } function drawBalloonVendorRight() { return artRowsSized([ '..P..Y..B.....', '..P..Y..B.....', '..PP.YY.BB....', '.....S........', '.....N........', '....NSSN......', '....SWWS......', '....SSSS......', '...RRRRRR.....', '...RRRRRR.....', '....BB.BB.....', '...BB...B.....', '...N....N.....', '..NN....NN....', '..............', '..............', '..............', '..............' ], {P:'#f38db6',Y:'#f3d454',B:'#8ecfff',S:'#7c5b48',N:'#27252b',W:'#fff6ef',R:'#d76b52'}, 14, 18); } function drawJellyCometRight() { return artRowsSized([ '............', '......C.....', '....CCCCC...', '..CCWWWWWCC.', '..CWWGGGWWC.', '..CWWWWWWWC.', '...CWWWWWC..', '....CCCCC...', '.....T.T....', '....T...T...', '............', '............' ], {C:'#98c3ff',W:'#dff0ff',G:'#5b5d8c',T:'#b7d8ff'}, 12, 12); } function drawCourierBikeRight() { return artRowsSized([ '................', '......NN........', '.....NSSN.......', '.....SWWS.......', '.....SSSS.......', '....RRRRRR......', '....RBBB.R......', '....R.BBBR......', '.....K.K........', '...OO...OO......', '..OOWOOOOWO.....', '...OO...OO......' ], {N:'#252732',S:'#f0c39b',W:'#fff7ef',R:'#4da0d8',B:'#6b4d39',K:'#4a4f58',O:'#d9dce5'}, 16, 12); } function drawGreatCedar() { return artRowsSized([ '..........GGGG..........', '........GGLLLLGG........', '......GGLLLWWLLGG.......', '.....GLLLWWWWLLLGG......', '....GLLLWWGGWWLLLG......', '...GLLLWWGGGGWWLLLG.....', '...GLLWWGGGGGGWWLLG.....', '..GLLWWGGDDDDGGWWLLG....', '..GLLWGGDDDDDDGGWLLG....', '.GLLWWGDDGGGGDDGWWLLG...', '.GLLWGGDGGGGGGDGGWLLG...', '.GLWWGGGGGGGGGGGGWWLG...', '.GLWGGGGGGGGGGGGGGWLG...', 'GLWWGGGGGGGGGGGGGGWWLG..', 'GLWGGGGGGGGGGGGGGGGWLG..', 'GLWGGGGGGGGGGGGGGGGWLG..', '.GLWGGGGGGGGGGGGGGWLG...', '.GLWWGGGGGGGGGGGGWWLG...', '.GLLWGGGGGGGGGGGGWLLG...', '.GLLWWGGGGGGGGGGWWLLG...', '..GLLWGGGGGGGGGGWLLG....', '..GLLWWGGGGGGGGWWLLG....', '...GLLWWGGGGGGWWLLG.....', '....GLLLWWGGWWLLLG......', '.....GLLLWWWWLLLG.......', '......GLLLTTLLLG........', '.......GLLTTLLG.........', '.......TTTTTTTT.........', '......TTTTTTTTTT........', '......TTTTRRTTTT........', '.....NNNNNNNNNNNN.......', '........................' ], { G: '#4ca35b', L: '#71c872', W: '#b7f2b6', D: '#3a7b44', T: '#7a4f30', R: '#c79664', N: '#4f3828' }, 24, 32); } function drawGreatCedarDepth() { return depthRowsSized([ '..........^^^^..........', '........^^^^^^^^........', '......^^^^^^^^^^^.......', '.....^^^^^^^^^^^^^^......', '....^^^^^^^^^^^^^^^......', '...^^^^^^^^^^^^^^^^^.....', '...^^^^^^^^^^^^^^^^^.....', '..^^^^^^^^^^^^^^^^^^^....', '..^^^^^^^^^^^^^^^^^^^....', '.^^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^^...', '^^^^^^^^^^^^^^^^^^^^^^^..', '^^^^^^^^^^^^^^^^^^^^^^^..', '^^^^^^^^^^^^^^^^^^^^^^^..', '.^^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^^...', '..^^^^^^^^^^^^^^^^^^^....', '..^^^^^^^^^^^^^^^^^^^....', '...^^^^^^^^^^^^^^^^^.....', '....^^^^^^^^^^^^^^^......', '.....^^^^^^^^^^^^^.......', '......^^^^----^^^........', '.......^^^----^^.........', '.......--------.........', '......----------........', '......----------........', '.....------------.......', '........................' ], 24, 32); } function drawMoonfallCascade() { return artRowsSized([ '......GGGGGGGGGGGG......', '....GGSSSSSSSSSSSSGG....', '...GSSSCCWWWWCCSSSSG....', '..GSSCCWWWWWWWWCCSSSG...', '..GSCWWWWBBBBWWWWCSG...', '.GSCWWWBBBBBBBBWWCSG...', '.GSWWWBBBTTTTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBTWWTBBBWWSG...', '.GSWWWBBBWWWWBBBWWSG...', '.GSWWWWWWWWWWWWWWWSG...', '..GSWWWWCCWWCCWWWSG....', '..GSWWWCCWWWWCCWWSG....', '...GSWWWWWWWWWWWSG.....', '....GSWWWWWWWWWWSG.....', '....GGWWWWWWWWWWGG.....', '.....WWTTTTTTTTWW......', '....WWTTTTTTTTTTWW.....', '....WTTTTTTTTTTTTW.....', '....WWWWWWWWWWWWWW.....', '....BBBBBBBBBBBBBB.....', '........................' ], { G: '#58a55d', S: '#6f7e87', C: '#c7f3ff', W: '#eafcff', B: '#58c0ea', T: '#7aaec8' }, 24, 32); } function drawMoonfallCascadeDepth() { return depthRowsSized([ '......^^^^^^^^^^^^......', '....^^^^^^^^^^^^^^^^....', '...^^^^^^^^^^^^^^^^^....', '..^^^^^^^^^^^^^^^^^^^...', '..^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^^^^^^^^^^^^^^...', '.^^^^^^^vvvv^^^^^^^...', '..^^^^^^vvvv^^^^^^....', '..^^^^^^vvvv^^^^^^....', '...^^^^^vvvv^^^^^.....', '....^^^^vvvv^^^^.....', '....^^^^vvvv^^^^.....', '.....------------......', '....--------------.....', '....--------------.....', '....--------------.....', '....--------------.....', '........................' ], 24, 32); } function drawSunblossomGate() { return artRowsSized([ '........YYYY........', '......YYOOOOYY......', '.....YOOOGGOOOY.....', '....YOOOGGGGOOOY....', '...YYOOGGWWGGOOYY...', '..YYOOGGWWWWGGOOYY..', '..YOOOGWWWWWWGOOOY..', '.YOOOGWW....WWGOOOY.', '.YOOOGW......WGOOOY.', '.YOOOGW......WGOOOY.', '.YOOOGW......WGOOOY.', '.YOOOGW......WGOOOY.', '.YOOOGW......WGOOOY.', '.YOOOGW......WGOOOY.', '..YOOOGG....GGOOOY..', '..YYOOOGGGGGGOOYY...', '...YYOONNNNNNOYY....', '....NNNNTTTTNNN.....', '....NNNNTTTTNNN.....', '....NNNNTTTTNNN.....', '....NNNNNNNNNNN.....', '.....GGGGGGGG.......', '.....GGGGGGGG.......', '........................' ], { Y: '#f3dd5c', O: '#ef9b43', G: '#69bf67', W: '#fff3d9', N: '#866b52', T: '#6a4a33' }, 24, 24); } function drawSunblossomGateDepth() { return depthRowsSized([ '........^^^^........', '......^^^^^^^^......', '.....^^^^^^^^^^.....', '....^^^^^^^^^^^^....', '...^^^^^^^^^^^^^^...', '..^^^^^^^^^^^^^^^^..', '..^^^^^^^^^^^^^^^^..', '.^^^^^^^....^^^^^^^.', '.^^^^^^......^^^^^^.', '.^^^^^^......^^^^^^.', '.^^^^^^......^^^^^^.', '.^^^^^^......^^^^^^.', '.^^^^^^......^^^^^^.', '.^^^^^^......^^^^^^.', '..^^^^^^....^^^^^^..', '..^^^^^^^^^^^^^^^^..', '...^^^^^------^^^...', '....----^^^^----....', '....----^^^^----....', '....----^^^^----....', '....-------------....', '.....--------.......', '.....--------.......', '........................' ], 24, 24); } function drawEchoCavern() { return artRowsSized([ '................................', '..........SSSSSSSSSS............', '.......SSSSGGGGGGGGSSSS.........', '.....SSGGGGGNNNNGGGGGGSS........', '....SGGGGNNNNNNNNNNGGGGS........', '...SGGGNNNNNNNNNNNNNNGGGS.......', '..SGGNNNNNNNN..NNNNNNNNGGS......', '.SGGNNNNNN......NNNNNNNGGS......', '.SGNNNNN..........NNNNNNGS......', '.SGNNNN............NNNNNGS......', '.SGNNN..............NNNNGS......', '.SGNNN..............NNNNGS......', '.SGNNNN............NNNNNGS......', '.SGNNNNN..........NNNNNNGS......', '.SGGNNNNNN......NNNNNNNGGS......', '..SGGNNNNNNNN..NNNNNNNNGGS......', '...SGGGNNNNNNNNNNNNNNGGGS.......', '....SGGGGNNNNNNNNNNGGGGS........', '.....SSGGGGGNNNNGGGGGSS.........', '......BBBBBBBBBBBBBBBB..........' ], { S: '#8a8c90', G: '#5ea362', N: '#21252d', B: '#556048' }, 32, 20); } function drawEchoCavernDepth() { return depthRowsSized([ '................................', '..........^^^^^^^^^^............', '.......^^^^^^^^^^^^^^^^.........', '.....^^^^^^^^^^^^^^^^^^^^........', '....^^^^^^^^^^^^^^^^^^^^^........', '...^^^^^^^^^^^^^^^^^^^^^^^.......', '..^^^^^^^^^^^^..^^^^^^^^^^......', '.^^^^^^^^^^......^^^^^^^^^......', '.^^^^^^^^..........^^^^^^^^......', '.^^^^^^^............^^^^^^^......', '.^^^^^^..............^^^^^^......', '.^^^^^^..............^^^^^^......', '.^^^^^^^............^^^^^^^......', '.^^^^^^^^..........^^^^^^^^......', '.^^^^^^^^^^......^^^^^^^^^......', '..^^^^^^^^^^^^..^^^^^^^^^^......', '...^^^^^^^^^^^^^^^^^^^^^^^.......', '....^^^^^^^^^^^^^^^^^^^^^........', '.....^^^^^^^^^^^^^^^^^^^.........', '......----------------..........' ], 32, 20); } function drawWorldrootShrine() { return artRowsSized([ '............GGGG............', '..........GGLLLLGG..........', '........GGLLLWWLLLGG........', '.......GLLLWWWWWWLLLG.......', '......GLLWWGGGGGGWWLLG......', '.....GLLWGGGGGGGGGGWLLG.....', '....GLLWGGGGTTGGGGGGWLLG....', '....GLLWGGGTTTTGGGGGWLLG....', '...GLLWGGTTTTTTTTGGGWLLG....', '...GLLWGGTTTNNNTTTGGWLLG....', '..GLLWGGTTTNNWWNNTTGGWLLG...', '..GLLWGGTTNNWWWWNNTGGWLLG...', '..GLLWGGTTNNWYYWNNTGGWLLG...', '..GLLWGGTTNNYYYYNNTGGWLLG...', '..GLLWGGTTNNWYYWNNTGGWLLG...', '..GLLWGGTTNNWWWWNNTGGWLLG...', '..GLLWGGTTTNNWWNNTTGGWLLG...', '...GLLWGGTTTNNNTTTGGWLLG....', '...GLLWGGTTTTTTTTGGGWLLG....', '....GLLWGGGTTTTGGGGGWLLG....', '....GLLWGGGGTTGGGGGGWLLG....', '.....GLLWGGGGGGGGGGWLLG.....', '......GLLWWGGGGGGWWLLG......', '.......GLLLWWTTWWLLLG.......', '........GLLLTTTTLLGG........', '.........TTTTTTTTTT.........', '........TTTTRRTTTTTT........', '.......NNNNNNNNNNNNNN.......' ], { G: '#4fa85c', L: '#7ad07b', W: '#c6f5be', T: '#7c5232', N: '#69523b', Y: '#ffe99a', R: '#c49668' }, 28, 28); } function drawWorldrootShrineDepth() { return depthRowsSized([ '............^^^^............', '..........^^^^^^^^..........', '........^^^^^^^^^^^^........', '.......^^^^^^^^^^^^^^.......', '......^^^^^^^^^^^^^^^^......', '.....^^^^^^^^^^^^^^^^^^.....', '....^^^^^^^^^^^^^^^^^^^^....', '....^^^^^^^^^^^^^^^^^^^^....', '...^^^^^^^^^^^^^^^^^^^^^^....', '...^^^^^^^^^^^^^^^^^^^^^^....', '..^^^^^^^^^^^^^^^^^^^^^^^^...', '..^^^^^^^^^^^^^^^^^^^^^^^^...', '..^^^^^^^^^^^^++^^^^^^^^^^...', '..^^^^^^^^^^^++++^^^^^^^^^...', '..^^^^^^^^^^^^++^^^^^^^^^^...', '..^^^^^^^^^^^^^^^^^^^^^^^^...', '..^^^^^^^^^^^^^^^^^^^^^^^^...', '...^^^^^^^^^^^^^^^^^^^^^^....', '...^^^^^^^^^^^^^^^^^^^^^^....', '....^^^^^^^^^^^^^^^^^^^^....', '....^^^^^^^^^^^^^^^^^^^^....', '.....^^^^^^^^^^^^^^^^^^.....', '......^^^^^^^^^^^^^^^^......', '.......^^^^^^^^^^^^^^.......', '........^^^^----^^^^........', '.........----------.........', '........------------........', '.......----------------.......' ], 28, 28); } function drawSmallRaisedDepth() { const d = Array(16 * 16).fill(0); for (let y = 2; y <= 12; y++) for (let x = 3; x <= 12; x++) d[y * 16 + x] = 1; return d; } function filledDepthFromPixels(pixels) { return filledDepthFromPixelsSized(pixels, 16, 16); } function filledDepthFromPixelsSized(pixels, width, height = width) { const w = clampDimension(width, 16); const h = clampDimension(height, w); const src = normalizePixels(pixels, w, h); const out = Array(w * h).fill(0); for (let i = 0; i < src.length; i++) { if (src[i]) out[i] = 1; } return out; } 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; } function drawAuroraObservatory() { return artRowsSized([ '............NNNN............', '..........NNYYYYNN..........', '.........NYYCCCCYYN.........', '........NYYCCCCCCYYN........', '.......NYYCCBBBBCCYYN.......', '......NYYCBBBBBBBBCYYN......', '......NYYBBBBBBBBBBYYN......', '.....NYYBBBBBBBBBBBBYYN.....', '.....NYYBBBBBBBBBBBBYYN.....', '.....NYYYYYYYYYYYYYYYYN.....', '....NNWWWWWWNNWWWWWWWWNN....', '....NWWWWWWN..NWWWWWWWWN....', '...NNWWWLLWNNNNWWLLWWWNN....', '...NWWWLLLLWWWWLLLLWWWWN....', '...NWWLLLLLLWWLLLLLLWWWN....', '...NWWLLDDLLWWLLDDLLWWWN....', '...NWWLLLLLLWWLLLLLLWWWN....', '...NWWWWWWWWWWWWWWWWWWWN....', '...NWWGGGGGGGGGGGGGGWWWN....', '...NWWGTTTGGTTTTGGTTGWWN....', '...NWWGTTTGGTTTTGGTTGWWN....', '...NWWGGGGGGGGGGGGGGWWWN....', '...NWWGGTTTGGGGGGTTGWWWN....', '...NWWGGTTTGGDDGGTTGWWWN....', '...NWWGGGGGGDDGGGGGGWWWN....', '....NWWWWWWWWDDWWWWWWWN.....', '....NNNNNNNNNNNNNNNNNN......', '............................' ], { N: '#212532', Y: '#8b74dd', C: '#d9edff', B: '#8fb7f5', W: '#746453', L: '#fff0b4', G: '#c1ab84', T: '#70563f', D: '#3d2f28' }, 28, 28); } function drawHearthwindMill() { return artRowsSized([ '..........NNNN..........', '.........NYYYYN.........', '.........NYWWYN.........', '........NYYWWYYN........', '........NNNWWNNN........', '.......N..NWWN..N.......', '...NNNNN..NWWN..NNNNN...', '..NPPPPNNNNWWNNNNPPPPN..', '..NPPPPPPNNWWNNPPPPPPN..', '..NPPPPPPPNNNNPPPPPPPN..', '..NPPPPPPPPPPPPPPPPPPN..', '..NQQQQQQQQQQQQQQQQQQN..', '..NQQQQQQQQQQQQQQQQQQN..', '..NQQLLQQQQQQQQQQLLQQN..', '..NQLLLLQQQNNQQLLLLQQN..', '..NQQLLQQQQNNQQQQLLQQN..', '..NQQQQQGGGGGGQQQQQQQN..', '..NQQGGGTTGGGGGGTTGQQN..', '..NQQGTTTTGGDDGGTTTGQN..', '..NQQGTTTTGGDDGGTTTGQN..', '..NQQGGGGGGGDDGGGGGGQN..', '..NQQQGGGGGGDDGGGGQQQN..', '..NQQQQQQQQQDDQQQQQQQN..', '..NQQQQQQQQQDDQQQQQQQN..', '..NQQQQQQQQQDDQQQQQQQN..', '..NQQQQQQQNNNNQQQQQQQN..', '...NNNNNNNN..NNNNNNNN...', '........................' ], { N: '#252934', Y: '#fff5d0', W: '#8bb8ff', P: '#8f5e47', Q: '#caa77f', L: '#ffe8a0', G: '#d6c29b', T: '#77543b', D: '#403027' }, 24, 28); } function drawTideglassLighthouse() { return artRowsSized([ '........NNNN........', '.......NYYYYN.......', '.......NYCCYN.......', '......NYYCCYYN......', '......NNNCCNNN......', '.......N.CC.N.......', '.......N.BB.N.......', '......NN.BB.NN......', '......NWWWWWWN......', '......NWWLLWWN......', '.....NNWWLLWWNN.....', '.....NWWWWWWWWN.....', '.....NWWGGGGWWN.....', '.....NWWGTTGWWN.....', '.....NWWGGGGWWN.....', '....NNWWWWWWWWNN....', '....NWWWWLLWWWWN....', '....NWWWWLLWWWWN....', '....NWWWWWWWWWWN....', '....NWWWGGGGWWWN....', '....NWWWGTTGWWWN....', '....NWWWGGGGWWWN....', '...NNWWWWWWWWWWNN...', '...NWWWWWWWWWWWWN...', '...NWWWGGWWGGWWWN...', '...NWWWTTWWTTWWWN...', '...NWWWGGWWGGWWWN...', '..NNWWWWWWWWWWWWNN..', '..NWWWWWWWWWWWWWWN..', '..NWWWWWWDDWWWWWWN..', '..NWWWWWWDDWWWWWWN..', '..NWWWWGGDDGGWWWWN..', '..NWWWWTTDDTTWWWWN..', '..NWWWWGGDDGGWWWWN..', '...NNNNNNDDNNNNNN...', '........NNNN........' ], { N: '#212632', Y: '#fff0ad', C: '#eef7ff', B: '#8fc1ff', W: '#f2efe8', L: '#fff0b2', G: '#d7c5a8', T: '#8f6a4e', D: '#413128' }, 20, 36); } function drawMossrailDepot() { return artRowsSized([ '....NNNNNNNNNNNNNNNNNNNNNNNN....', '...NYYYYYYYYYYYYYYYYYYYYYYYYN...', '..NYYNNNNNNNNNNNNNNNNNNNNNNYYN..', '..NYYNPPPPPPPPPPPPPPPPPPPPNYYN..', '..NYYNPPPPPPPPPPPPPPPPPPPPNYYN..', '..NYYNNNNNNNNNNNNNNNNNNNNNNYYN..', '.NNWWWWWWWWWWWWWWWWWWWWWWWWWWNN.', '.NWWWWWWWWWWWWWWWWWWWWWWWWWWWWN.', '.NWWLLLLWWWWLLLLWWWWLLLLWWWWWN..', '.NWWLLLLWWWWLLLLWWWWLLLLWWWWWN..', '.NWWWWWWWWWWWWWWWWWWWWWWWWWWWN..', '.NWWGGGGGGGGGGGGGGGGGGGGGGWWWN..', '.NWWGTTTGGGGGGGGGGGGGGTTTGWWWN..', '.NWWGGGGGGGGGNNNNGGGGGGGGGWWWN..', '.NWWGTTTGGGGGNDDNGGGGGTTTGWWWN..', '.NWWGGGGGGGGGNDDNGGGGGGGGGWWWN..', '.NWWWWWWWWWWWWDDWWWWWWWWWWWWWN..', '.NWWWWWWWWWWWWDDWWWWWWWWWWWWWN..', '.NNNNNNNNNNNNNNNNNNNNNNNNNNNNNN.', '................................' ], { N: '#232733', Y: '#f5d48f', P: '#7b5651', W: '#a9b8bf', L: '#ffeda7', G: '#d0b48d', T: '#7d5b45', D: '#3b2f2a' }, 32, 20); } function drawLanternLibrary() { return artRowsSized([ '.......NNNNNNNNNNNNNN.......', '......NYYYYYYYYYYYYYYN......', '.....NYYNNNNNNNNNNNNYYN.....', '.....NYNPPPPPPPPPPPPNYN.....', '....NNNNNNNNNNNNNNNNNNNN....', '....NWWWWWWWWWWWWWWWWWWN....', '...NNWWWLLLWWNNWWLLLWWWNN...', '...NWWWLLLLWWWWWWLLLLWWWN...', '...NWWWWLLWWWWWWWWLLWWWWN...', '...NWWWWWWWWWWWWWWWWWWWWN...', '...NWWGGGGGGGGGGGGGGGGWWN...', '...NWWGTTTGGTTGGTTGGTTGWN...', '...NWWGGGGGGGGGGGGGGGGWWN...', '...NWWGTTTGGTTGGTTGGTTGWN...', '...NWWGGGGGGGGGGGGGGGGWWN...', '...NWWGGTTGGGGGGGGTTGGWWN...', '...NWWGGTTGGGNNDGGTTGGWWN...', '...NWWGGGGGGGNNDGGGGGGWWN...', '...NWWWWWWWWWWDDWWWWWWWWN...', '...NWWWWWWWWWWDDWWWWWWWWN...', '....NWWWWWWWWNNNNWWWWWWN....', '....NNNNNNNNN....NNNNNN.....', '............................', '............................' ], { N: '#222633', Y: '#e7c36c', P: '#704944', W: '#8a6a4a', L: '#ffeeb5', G: '#d7c0a0', T: '#6b5139', D: '#382c26' }, 28, 24); } function drawVelvetTheater() { return artRowsSized([ '....NNNNNNNNNNNNNNNNNNNN.....', '...NYYYYYYYYYYYYYYYYYYYYN....', '..NYYNNNNNNNNNNNNNNNNNNYYN...', '..NYNPPPPPPPPPPPPPPPPPPNYN...', '.NNNNNNNNNNNNNNNNNNNNNNNNNN..', '.NWWWWWWWWWWWWWWWWWWWWWWWWN..', '.NWWLLLLLLLLWWWWLLLLLLLLWWN..', '.NWWLAAAAALLWWWWLLAAAAALWWN..', '.NWWLLLLLLLLWWWWLLLLLLLLWWN..', '.NWWWWWWWWWWWWWWWWWWWWWWWWN..', '.NWWGGGGGGGGGGGGGGGGGGGGWWN..', '.NWWGTTGGTTGGGGGGTTGGTTGWWN..', '.NWWGGGGGGGGGNNGGGGGGGGGWWN..', '.NWWGTTGGTTGGNDDNGGTTGGTGWWN.', '.NWWGGGGGGGGGNDDNGGGGGGGGWWN.', '.NWWWWWWWWWWWDDDWWWWWWWWWWN..', '.NWWWWWWWWWWNDDDNWWWWWWWWWN..', '.NNNNNNNNNNNNNNNNNNNNNNNNNN..', '............................', '............................' ], { N: '#231f2c', Y: '#f2bf68', P: '#7f2d47', W: '#5d4858', L: '#ffd692', A: '#f08ec0', G: '#b99a7d', T: '#654633', D: '#39262a' }, 28, 20); } function drawBloomBridgeGate() { return artRowsSized([ '..........NNNN..........', '........NNYYYYNN........', '.......NYYPPPPYYN.......', '......NYYPYYYYPYYN......', '.....NYYPYYNNYYPYYN.....', '....NNYYYYN..NYYYYNN....', '...NNWWWWWN..NWWWWWNN...', '..NNWWWNNWWNNWWNNWWWNN..', '..NWWWN..NWWWWN..NWWWN..', '..NWWN....NWWN....NWWN..', '..NWWN....NWWN....NWWN..', '..NWWN....NWWN....NWWN..', '..NWWNNNNNNWWNNNNNNWWN..', '..NWWGGGGGGWWGGGGGGWWN..', '..NWWGTTTGGWWGGTTTGWWN..', '..NWWGGGGGGWWGGGGGGWWN..', '..NWWPPPPPPWWPPPPPPWWN..', '..NWWPPYYPPWWPPYYPPWWN..', '..NWWPPPPPPWWPPPPPPWWN..', '...NNWWWWWWWWWWWWWWNN...', '....NNNNNNWWNNNNNNNN....', '........NNWWNN..........', '........NWWWWN..........', '........NNNNNN..........' ], { N: '#242835', Y: '#f6d56c', P: '#f29fc3', W: '#7f6a5a', G: '#9aca7c', T: '#5b8e49' }, 24, 24); } function drawStarreefSubmarine() { return artRowsSized([ '................................', '.............NNNNNN.............', '..........NNNYYYYYYNNN..........', '........NNYYCCCCCCCCYYNN........', '......NNYYCCBBBBBBBBCCYYNN......', '.....NYYCCBBBBBBBBBBBBCCYYN.....', '....NYYCBBBBBNNNNBBBBBBCYYN.....', '...NYYCBBBBBNGGGGNBBBBBCYYN.....', '..NNYYBBBBBBNGTTGNBBBBBBYYNN....', '.NNYYYYYYYYYNGGGGNYYYYYYYYYNN...', '.NWWWWWWWWWWNNNNNNWWWWWWWWWWWN..', '.NWWWWLLWWWWWWWWWWWWLLWWWWWWWN..', '.NNWWWWWWWWWWWWWWWWWWWWWWWWWNN..', '..NNNNNNNNNNNNNNNNNNNNNNNNNN....', '................................', '................................' ], { N: '#1f2431', Y: '#f4dc7a', C: '#d0f1ff', B: '#57b7d7', W: '#73544d', G: '#eaf5ff', T: '#89b2ff', L: '#fff2b6' }, 32, 16); } function drawAstrolabeCourierRight() { return artRowsSized([ '....NNNN....', '...NYYYYN...', '...NYYYYN...', '...NNNNNN...', '..NNWWWWNN..', '..NWWCCWWN..', '..NWWWCWWN..', '...NWWWWN...', '..NNWWWWNN..', '.NNWWNNWWNN.', '.NWWN..NWWN.', '.NWWNNNNWWN.', '.NWWWGGWWWN.', '.NWWNGGNWWN.', '..NNN..NNN..', '...N....N...' ], { N: '#232833', Y: '#d6a17a', W: '#375476', C: '#d9eeff', G: '#9f6e46' }, 12, 16); } function drawPuddleToadRight() { return artRowsSized([ '............', '...NN..NN...', '..NYYNNYYN..', '.NYYYYYYYYN.', '.NYYNYYNYYN.', '.NYYYYYYYYN.', '.NWWYYYYWWN.', '..NWWWWWWN..', '...NNNNNN...', '............' ], { N: '#202632', Y: '#7eb85c', W: '#d9eeff' }, 12, 10); } function drawRibbonSwanRight() { return artRowsSized([ '............', '......NN....', '.....NYYN...', '....NYYYYN..', '...NYYNNYYN.', '.NNNYYNNYYN.', 'NWWWWYYYYYN.', 'NWWWWWWWWNN.', '.NWWWWWWWN..', '..NWWWWWN...', '...NNNNN....', '............' ], { N: '#222633', Y: '#ffd3dc', W: '#f7fbff' }, 12, 12); } function drawLanternBeetleRight() { return artRowsSized([ '............', '....NNNN....', '...NYYYYN...', '..NYYCCYYN..', '..NYYCCYYN..', '.NNYYNNYYNN.', '.NWWYYYYWWN.', '..NWWWWWWN..', '..NWN..NWN..', '.NNN....NNN.', '.N........N.', '............' ], { N: '#202531', Y: '#f0d25f', C: '#fff1b5', W: '#7d4933' }, 12, 12); } function drawSmilePortrait() { return artRowsSized([ '......FFFFFFFFFFFF......', '.....FNNNNNNNNNNNNF.....', '....FNBBBBBBBBBBBBNF....', '....FNBHHHHHHHHHHBNF....', '....FNBHHHSSSSHHHBNF....', '....FNBHHSSSSSSHHBNF....', '....FNBHHSSSSSSHHBNF....', '....FNBHHSEEESHHHBNF....', '....FNBHHSSSSSSHHBNF....', '....FNBHHHSDDSSHHBNF....', '....FNBGGGSSSSGGGBNF....', '....FNBGGGGGGGGGGBNF....', '....FNBGGGGGGGGGGBNF....', '....FNBGGGGGGGGGGBNF....', '....FNBGGGGGGGGGGBNF....', '....FNBGGTTGGGGTGBNF....', '....FNBGTTTTGGTTTBNF....', '....FNBGTTTTGGTTTBNF....', '....FNBGGTTTGGTTGBNF....', '....FNBGGGGGGGGGGBNF....', '....FNBGGGGDDGGGGBNF....', '....FNBGGGGDDGGGGBNF....', '....FNBGGGGDDGGGGBNF....', '....FNBGGGGDDGGGGBNF....', '....FNBGGGGDDGGGGBNF....', '....FNBGGGDDDDGGGBNF....', '....FNBGGGDDDDGGGBNF....', '....FNBBBBBBBBBBBBNF....', '....FNNNNNNNNNNNNNNF....', '.....FFFFFFFFFFFFFF.....', '........................', '........................' ], { F: '#a67c37', N: '#252836', B: '#67816b', H: '#4b3228', S: '#d5ad87', E: '#242834', D: '#5f4130', G: '#4e6b3f', T: '#395835' }, 24, 32); } function drawGreatWavePanel() { return artRowsSized([ '....FFFFFFFFFFFFFFFFFFFFFFFF....', '...FNNNNNNNNNNNNNNNNNNNNNNNNF...', '..FNCCCCCCCCCCCCCCCCCCCCCCCCNF..', '..FNCWWWWWWWWWWWWWWWWWWCCCCCNF..', '..FNCWWWWWWWWWWWWWWWWWCCCCCCNF..', '..FNCWWWWWWWWWWWWWWCCCCCYYCCNF..', '..FNCWWWWWWWWWWWWCCCCCCCCCMMNF..', '..FNCWWWWWWWWWWCCCCCAAACCCCMNF..', '..FNCWWWWWWWWCCCCCAAAAAACCCMNF..', '..FNCWWWWWWCCCCCAAAWWWAAACCMNF..', '..FNCWWWWCCCCCAAAWWWWWWAACCMNF..', '..FNCWWWCCCAAACWWWWWWWWWACCMNF..', '..FNCCWCCAAAACWWWWWWWWWWWCCMNF..', '..FNCCCAAAAACWWWWWWWWWWWWWCMNF..', '..FNCCAAAAACWWWWWWWWWWWWWWMNF..', '..FNCCAAAACCWWWWWWWWWWWWWWMNF..', '..FNCCCAACCCWWWWWWWWWWWWWWMNF..', '..FNCCCCCCCCWWWWWWWWWWWWWWMNF..', '..FNCCMMMMMCCCCCMMMMMMMCCCMNF..', '..FNCCMMMMMMMCCMMMMMMMMCCCMNF..', '..FNCCMMMMMMMMMMMMMMMMMCCCMNF..', '..FNCCMMMMMMMMMMMMMMMMMCCCMNF..', '..FNCCCCCCCCCCCCCCCCCCCCCCCNF..', '..FNNNNNNNNNNNNNNNNNNNNNNNNNF..' ], { F: '#a67c37', N: '#252836', C: '#d3eaf9', W: '#4a97d6', A: '#ffffff', Y: '#f3d36a', M: '#745f56' }, 32, 24); } function drawSunflowerStillLife() { return artRowsSized([ '........NN..NN..........', '.......NYYNNYYN.........', '......NYYYYYYYYN........', '.......NYYNNYYN.........', '........NN..NN..........', '..........GG............', '.....NN..NN..NN..NN.....', '....NYYNNYYNNYYNNYYN....', '...NYYYYYYYYYYYYYYYYN...', '....NYYNNYYNNYYNNYYN....', '.....NN..NN..NN..NN.....', '...........GG...........', '......NN..NN..NN........', '.....NYYNNYYNNYYN.......', '....NYYYYYYYYYYYYN......', '.....NYYNNYYNNYYN.......', '......NN..NN..NN........', '........GGGG............', '........GGGG............', '.......NBBBBBBN.........', '......NBBBBBBBBN........', '......NBBBBBBBBN........', '......NBBBBBBBBN........', '.......NBBBBBBN.........', '........NBBBBN..........', '........NBBBBN..........', '.......NMMMMMMN.........', '......NMMMMMMMMN........', '......NNNNNNNNNN........', '........................', '........................', '........................' ], { N: '#252836', Y: '#f0cc57', G: '#5e8b48', B: '#b48255', M: '#8c6a53' }, 24, 32); } function drawRosettaStela() { return artRowsSized([ '........NNNNNNNN........', '.......NSSSSSSSSN.......', '......NSSSSSSSSSSN......', '......NSSSSSSSSSSN......', '......NSSLLLSSLLSN......', '......NSSSSSSSSSSN......', '......NSSLLSSLLSSN......', '......NSSSSSSSSSSN......', '......NSSSLLLSSSSN......', '......NSSSSSSSSSSN......', '......NSSLLSSSSLSN......', '......NSSSSSSSSSSN......', '......NSSLLLSSLLSN......', '......NSSSSSSSSSSN......', '......NSSLLSSLLSSN......', '......NSSSSSSSSSSN......', '......NSSSLLLSSSSN......', '......NSSSSSSSSSSN......', '......NSSLLSSSSLSN......', '......NSSSSSSSSSSN......', '......NSSLLLSSLLSN......', '......NSSSSSSSSSSN......', '......NSSLLSSLLSSN......', '......NSSSSSSSSSSN......', '......NSSSLLLSSSSN......', '......NSSSSSSSSSSN......', '......NSSSSDDSSSSN......', '......NSSSSDDSSSSN......', '......NSSSSDDSSSSN......', '.......NSSSDDSSSN.......', '........NNNNNNNN........', '........................' ], { N: '#252836', S: '#887b66', L: '#5d5446', D: '#6e5842' }, 24, 32); } function drawPharaohMask() { return artRowsSized([ '.........NNNNNN.........', '........NYYYYYYN........', '.......NYYBBBBYYN.......', '......NYYBBBBBBYYN......', '......NYYBSSSSBYYN......', '......NYYBSSSSBYYN......', '......NYYBSDDSBYYN......', '......NYYBSEESBYYN......', '......NYYBSSSSBYYN......', '......NYYBSDDSBYYN......', '......NYYBSSSSBYYN......', '......NYYBSSSSBYYN......', '......NYYBSDDSBYYN......', '......NYYBSSSSBYYN......', '......NYYBSSSSBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '......NYYBBSSBBYYN......', '.......NYYBSSBYYN.......', '........NYYSSYYN........', '........NYYSSYYN........', '.........NNNNNN.........', '........................' ], { N: '#252836', Y: '#e0b94d', B: '#4a78bf', S: '#d7b585', E: '#232834', D: '#8c6035' }, 24, 32); } function drawStoneCircle() { return artRowsSized([ '................................', '................................', '............NN..NN............', '..........NNSSNNSSNN..........', '........NNSSSSSSSSSSNN........', '.......NSSSSSSSSSSSSSSN.......', '......NSSSSSSSSSSSSSSSSN......', '.....NSSSNSSSN..NSSSNSSSN.....', '....NSSSNSSSSN..NSSSSNSSSN....', '....NSSSNSSSSN..NSSSSNSSSN....', '....NSSSNSSSSN..NSSSSNSSSN....', '....NSSSNSSSSN..NSSSSNSSSN....', '....NSSSNSSSSNNNNSSSSNSSSN....', '....NSSSNSSSSSSSSSSSSNSSSN....', '....NSSSNSSSSSSSSSSSSNSSSN....', '.....NSSSNSSSSSSSSSSNSSSN.....', '......NSSSSSSSSSSSSSSSSN......', '.......NSSSSSSSSSSSSSSN.......', '........NNNNNNNNNNNNNN........', '........GGGGGGGGGGGGGG........', '.......GGTTGGGGGGGGTTGG.......', '.......GGGGGGGGDDGGGGGG.......', '.......GGGGGGGGDDGGGGGG.......', '........NNNNNNNNNNNNNN........' ], { N: '#252836', S: '#8b8274', G: '#93b670', T: '#5d8d46', D: '#6c4a35' }, 32, 24); } function drawTerracottaSentinel() { return artRowsSized([ '.......NNNNNN.......', '......NBBBBBBN......', '.....NBBSSSSBBN.....', '.....NBSSSSSSBN.....', '.....NBSSDDSSBN.....', '.....NBSSEESSBN.....', '.....NBSSSSSSBN.....', '.....NBBSSSSBBN.....', '......NBBBBBBN......', '......NBBBBBBN......', '......NBBBBBBN......', '......NBBBBBBN......', '......NBBBBBBN......', '.....NBBBBBBBBN.....', '.....NBBBGGBBBN.....', '.....NBBBGGBBBN.....', '.....NBBBGGBBBN.....', '.....NBBBGGBBBN.....', '.....NBBBGGBBBN.....', '.....NBBBBBBBBN.....', '.....NBBNNNNBBN.....', '.....NBN....NBN.....', '....NBBN....NBBN....', '....NBBN....NBBN....', '....NBBN....NBBN....', '....NBBN....NBBN....', '....NBBN....NBBN....', '....NBBN....NBBN....', '....NMMN....NMMN....', '....NMMN....NMMN....', '.....NN......NN.....', '....................' ], { N: '#252836', B: '#9a6a4f', S: '#be8964', E: '#1f2230', D: '#6f4838', G: '#7a5746', M: '#6b5143' }, 20, 32); } function drawRainBellTower() { return artRowsSized([ '........NNNN........', '.......NYYYYN.......', '......NYYYYYYN......', '......NYYNNYYN......', '......NNNWWNNN......', '.......NWWWWN.......', '.......NWWWWN.......', '......NNWWWWNN......', '......NWWGGWWN......', '......NWWGGWWN......', '......NWWGGWWN......', '......NWWLLWWN......', '......NWWGGWWN......', '.....NNWWWWWWNN.....', '.....NWWGGGGWWN.....', '.....NWWGTTGWWN.....', '.....NWWGGGGWWN.....', '.....NWWWWWWWWN.....', '.....NWWWLLWWWN.....', '.....NWWWGGWWWN.....', '.....NWWWGGWWWN.....', '.....NWWWGGWWWN.....', '....NNWWWGGWWWNN....', '....NWWWWWWWWWWN....', '....NWWWGGGGWWWN....', '....NWWWGTTGWWWN....', '....NWWWGGGGWWWN....', '....NWWWWWWWWWWN....', '....NWWWWDDWWWWN....', '....NWWWWDDWWWWN....', '....NWWGGDDGGWWN....', '....NWWGGDDGGWWN....', '....NWWWWDDWWWWN....', '.....NNNNDDNNNN.....', '........NNNN........', '....................' ], {N:'#242836',Y:'#f4d276',W:'#d9d8df',G:'#a49da3',L:'#fff0b6',T:'#6a5245',D:'#40322c'}, 20, 36); } function drawCrimsonPagoda() { return artRowsSized([ '.........NNNNNN.........', '.......NNYYYYYYNN.......', '......NYYYYYYYYYYN......', '......NNNNWWNNNNNN......', '.....NNRRRRRRRRRRNN.....', '.....NWRRRRRRRRRRWN.....', '....NNWWWWWWWWWWWWNN....', '....NWWWWWWWWWWWWWWN....', '...NNRRRRRRRRRRRRRRNN...', '...NWRRLLRRRRRRLLRRWN...', '...NWWWWWWWWWWWWWWWWN...', '..NNWWWWWWWWWWWWWWWWNN..', '..NWRRRRRRRRRRRRRRRRWN..', '..NWRRLLRRRNNRRRLLRRWN..', '..NWWWWWWWWNNWWWWWWWWN..', '.NNWWWWWWWWWWWWWWWWWWNN.', '.NWRRRRRRRRRRRRRRRRRRWN.', '.NWRRLLRRRRRRRRRRLLRRWN.', '.NWWWWWWWWWWWWWWWWWWWWN.', '.NWWGGGGGGGGGGGGGGGGWWN.', '.NWWGTTTGGTTTTGGTTTGWWN.', '.NWWGGGGGGGGGGGGGGGGWWN.', '.NWWWWWWWWWWWWWWWWWWWWN.', '.NWWWWWWWWWWWWWWWWWWWWN.', '.NWWGGGGGGGGDDGGGGGGWWN.', '.NWWGTTTGGGGDDGGGTTGWWN.', '.NWWGGGGGGGGDDGGGGGGWWN.', '.NWWWWWWWWWWDDWWWWWWWWN.', '.NWWWWWWWWWWDDWWWWWWWWN.', '..NNNNNNNNNNNNNNNNNNNN..', '........................', '........................' ], {N:'#242833',Y:'#efc05e',W:'#4f403f',R:'#9c4354',L:'#ffd99f',G:'#cbb28d',T:'#6c513b',D:'#362a25'}, 24, 32); } function drawMeteorForge() { return artRowsSized([ '................................', '........NNNNNNNNNNNN............', '......NNYYYYYYYYYYYYNN..........', '.....NYYNNNNNNNNNNNNYYN.........', '....NNNNNNNNNNNNNNNNNNNN........', '...NWWWWWWWWWWWWWWWWWWWWN.......', '..NNWWWLLLLWWWWWWLLLLWWWNN......', '..NWWWLLLLLLWWWWLLLLLLWWWN......', '..NWWWWLLLLLWWWWLLLLLWWWWN......', '..NWWWWWWWWWWWWWWWWWWWWWWN......', '.NNWWWWWWWWWWWWWWWWWWWWWWNN.....', '.NWWGGGGGGGGGGGGGGGGGGGGWWN.....', '.NWWGTTGGGTTGGGGGGTTGGGTGWN.....', '.NWWGGGGGGGGGNNNNGGGGGGGGWN.....', '.NWWGTTGGGGGGNDDNGGGGGTTGWN.....', '.NWWGGGGGGGGGNDDNGGGGGGGGWN.....', '.NWWWWWWWWWWWWDDWWWWWWWWWWN.....', '.NWWWWLLWWWWWWDDWWWWWWLLWWN.....', '.NWWWWWWWWWWWWDDWWWWWWWWWWN.....', '.NWWGGGGGGGGGGDDGGGGGGGGWWN.....', '.NWWGTTTGGGGGGDDGGGGGTTTGWN.....', '.NWWGGGGGGGGGGDDGGGGGGGGWWN.....', '.NNNNNNNNNNNNNNNNNNNNNNNNNN.....', '................................' ], {N:'#242833',Y:'#6f8fd8',W:'#5f544f',L:'#ffcf8a',G:'#b9916a',T:'#ff8f52',D:'#352924'}, 32, 24); } function drawInkGardenScreen() { return artRowsSized([ 'NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCMMMCCCCCCCCCCCCCCCCCCCCCCMMM CN'.replace(/ /g,''), 'NCMMMMCCCCCCCCCCCCCCCCCCCCMMMMCN', 'NCMMMMMCCCCCCCGGGGCCCCCCMMMMMCN', 'NCMMMMMMCCCCCGGGGGGCCCCMMMMMMCN', 'NCMMMMMMMCCCCGGGGGGCCCCMMMMMMCN', 'NCMMMMMMCCCCCGGGGGGCCCCMMMMMMCN', 'NCMMMMMCCCCCCCGGGGCCCCMMMMMMCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCCCCCCCCCCCCCNNNNCCCCCCCCCCCCN', 'NCCCCCCCCCCCCNDDDDNCCCCCCCCCCCN', 'NCCCCCCCCCCCCNDDDDNCCCCCCCCCCCN', 'NCCCCCCCCCCCCNNNNNNCCCCCCCCCCCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCGGGCCCCCCCCCCCCCCCCCCCCGGGCCN', 'NCGTGCCCCCCCCCCCCCCCCCCCCGTGCCN', 'NCGGGCCCCCCCCCCCCCCCCCCCCGGGCCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NCCCCCCCCCCCCCCCCCCCCCCCCCCCCCN', 'NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN' ], {N:'#252836',C:'#ece6d8',M:'#2a554c',G:'#7ab08a',T:'#4f8a5e',D:'#716159'}, 32, 24); } function drawMeadowTotem() { return artRowsSized([ '........NNNN........', '.......NYYYYN.......', '......NYYGGYYN......', '......NYYGGYYN......', '......NYYNNYYN......', '......NYYSSYYN......', '......NYYSSYYN......', '......NYYNNYYN......', '......NYYGGYYN......', '......NYYGGYYN......', '......NYYSSYYN......', '......NYYSSYYN......', '......NYYNNYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYYYYYN......', '......NYYDDYYN......', '......NYYDDYYN......', '......NYYDDYYN......', '......NYYDDYYN......', '.......NDDDDN.......', '........NNNN........', '....................' ], {N:'#252836',Y:'#a57b5c',G:'#97cb6f',S:'#f1d691',D:'#5b4337'}, 20, 28); } function drawTwilightCaravan() { return artRowsSized([ '................................', '...........NNNNNNNN.............', '........NNNYYYYYYYYNN...........', '......NNYYPPPPPPPPYYNN..........', '.....NYYPPPPPPPPPPPPYYN.........', '....NYYPPPPPPPPPPPPPPYYN........', '...NNWWWWWWWWWWWWWWWWWWNN.......', '...NWWWWLLWWWWLLWWWWLLWWN.......', '...NWWWWWWWWWWWWWWWWWWWWN.......', '...NWWGGGGGGGGGGGGGGGGWWN.......', '...NWWGTTTGGTTGGTTGGTTGWN.......', '...NWWGGGGGGGGGGGGGGGGWWN.......', '..NNWWWWWWWWWWWWWWWWWWWWNN......', '..NWWWWNNWWWWNNWWWWNNWWWWN......', '..NWWWN..NWWWN..NWWWN..NWWN......', '..NNNN....NNNN....NNNN..NN......' ], {N:'#252836',Y:'#c49b5b',P:'#7e5aa8',W:'#7b6654',L:'#ffe09c',G:'#8db873',T:'#5d8b45'}, 32, 16); } function drawPepperFoxRight() { return artRowsSized([ '................', '................', '.......NN.......', '......NYYN......', '.....NYYYYNN....', '....NYYYYYYN....', '...NYYYYYYYYN...', '...NYYYYNYYYN...', '..NYYYYYYYNWN...', '..NYYYYYYYWWN...', '..NYYYYYYWWN....', '...NYYYYWWN.....', '....NYYWWWNN....', '....NWWWWWWN....', '.....NN..NN.....', '................' ], {N:'#252836',Y:'#d56f3f',W:'#fff1dd'}, 16, 16); } function drawGlassMantaRight() { return artRowsSized([ '................', '.......NN.......', '.....NNCCNN.....', '...NNCCCCCCNN...', '..NCCCCCCCCCCN..', '.NCCCCCCCCCCCCN.', 'NNCCCCCCCCCCCCNN', '.NCCCCCCCCCCCCN.', '..NNCCCCCCCCNN..', '....NNCCCCNN....', '......NCCN......', '.......NN.......' ], {N:'#252836',C:'#7fdaf2'}, 16, 12); } function drawFestivalDrummerRight() { return artRowsSized([ '.....NNNN.....', '....NYYYYN....', '....NYYYYN....', '....NNNNNN....', '...NNRRRRNN...', '...NRRWWRRN...', '...NRRWWRRN...', '....NWWWWN....', '..NNNWWWWNN...', '.NNWWNNNNWWN..', '.NWWN.TTNWWN..', '.NWWNNNNNWWN..', '.NWWN....NWN..', '.NWWN....NWN..', '..NN......NN..', '..............' ], {N:'#252836',Y:'#e0b18c',R:'#d4505f',W:'#425fb4',T:'#a26e4b'}, 14, 16); } function drawBloomSpriteRight() { return artRowsSized([ '............', '....NNNN....', '...NYYYYN...', '..NYYGGYYN..', '..NYYGGYYN..', '.NNYYYYYYNN.', '.NWWYYYYWWN.', '..NWWWWWWN..', '..NWN..NWN..', '.NN....NNN..', '............', '............' ], {N:'#252836',Y:'#ffd3a8',G:'#ff8fc0',W:'#7bc56a'}, 12, 12); } window.PixelIslandDebug = { ...(window.PixelIslandDebug || {}), applySyncEvent }; bootstrap(); })();