pixel/app.js
2026-06-03 03:13:14 +09:00

10318 lines
420 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
'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 MAX_DYNAMIC_STEPS_PER_FRAME = 3;
const MAX_SPRITE_CACHE_ENTRIES = 260;
const DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER = 5;
// TEMPORARY MODERATION / ADMIN EASEMENT:
// While this flag is true, every collection work can be deleted regardless of owner.
// Set back to false to restore the original owner-only deletion rule in shared worlds.
const TEMP_ALLOW_DELETE_ALL_WORKS = true;
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const MODULES = window.PixelIslandModules || {};
const 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: 'server',
dynamicMotion: 'server'
});
function serverAuthority() {
ensureWorldProtectionState();
state.serverSync.authority = { ...DEFAULT_SERVER_AUTHORITY, ...(state.serverSync.authority || {}) };
return state.serverSync.authority;
}
function isServerAuthoritative(feature) {
return serverAuthority()[feature] === 'server';
}
function getAuthoritativeNow() {
const clock = state.serverSync?.clock || null;
if (isServerAuthoritative('dayNight') && clock && Number.isFinite(clock.worldTimeMs)) {
const syncedAt = Number(clock.syncedAt || Date.now());
return Number(clock.worldTimeMs) + Math.max(0, Date.now() - syncedAt);
}
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' ? 'dynamic' : 'static';
}
function roleToSubtype(role) {
return role || 'other';
}
function subtypeToRole(asset) {
const subtype = asset?.subtype;
if (['human', 'animal', 'bird', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype;
if (subtype === 'water') return 'other';
if (asset?.category === 'dynamic') return 'animal';
return 'other';
}
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'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'),
paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'),
lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'),
settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), checkOnIsland: $('checkOnIsland'), newAsset: $('newAsset'),
lineageNote: $('lineageNote'), assetList: $('assetList'), likedCodex: $('likedCodex'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'),
exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'),
exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats'), guardrailStats: $('guardrailStats'), validateWorld: $('validateWorld'), exportModerationReport: $('exportModerationReport'), clearReports: $('clearReports'),
settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'), displayLimit: $('displayLimit'), rotationStats: $('rotationStats'),
reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName')
};
const ctx = els.canvas.getContext('2d', { alpha: false });
const pctx = els.paintCanvas.getContext('2d', { alpha: true });
ctx.imageSmoothingEnabled = false;
pctx.imageSmoothingEnabled = false;
let dpr = window.devicePixelRatio || 1;
let cw = 1;
let ch = 1;
let world = makeWorld();
let terrainCache = buildTerrainCache(world);
let state = loadState();
let selectedAssetId = state.assets[0]?.id ?? null;
let mode = 'inspect';
let view = { x: 0, y: 0, zoom: 1 };
let pointer = {
down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0,
dragging: false, downTime: 0, button: 0
};
let 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 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 = 'a';
let advancedDraw = false;
let depthPixels = blankPixels(8).map(() => 0);
let depthPaintMode = 1;
let isPainting = false;
let staticKind = 'nature';
let dynamicKind = 'human';
let lightPixels = [];
let 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 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();
updateSyncStats();
updateGuardrailStats();
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'); clearWorldSelection(false); setDrawerOpen(true); });
els.closeEditor.addEventListener('click', () => setDrawerOpen(false));
els.tabs.forEach((button) => {
button.addEventListener('click', () => setTab(button.dataset.tab));
});
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();
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', () => {
advancedDraw = !advancedDraw;
toggleHidden(els.advancedToolGroup, !advancedDraw);
toggleHidden(els.toolLight, !advancedDraw);
toggleHidden(els.toolParticle, !advancedDraw);
toggleHidden(els.toolDepth, !advancedDraw);
toggleHidden(els.depthHigh, !advancedDraw);
toggleHidden(els.depthLow, !advancedDraw);
toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle');
toggleHidden(els.advancedHint, !advancedDraw);
els.toggleAdvanced.classList.toggle('active', advancedDraw);
els.drawer?.classList.toggle('advancedTools', advancedDraw);
if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush');
updateParticleUI();
drawEditor();
});
els.clearPaint.addEventListener('click', () => {
if (!confirm('Clear the entire canvas? This removes all pixels, lights, depth, particles, and selection.')) return;
commitEditorMutation(() => {
editorPixels = blankPixels(editorSize);
editorLeftPixels = 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.exportPng?.addEventListener('click', exportEditorPng);
els.importPng?.addEventListener('click', () => els.pngImportInput?.click());
els.pngImportInput?.addEventListener('change', importEditorPng);
els.undoPaint?.addEventListener('click', undoEditor);
els.redoPaint?.addEventListener('click', redoEditor);
window.addEventListener('keydown', onEditorKeyDown);
els.voteUp?.addEventListener('click', () => voteSelected(1));
els.voteDown?.addEventListener('click', () => voteSelected(-1));
els.bubbleRemix?.addEventListener('click', () => remixSelected());
els.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.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.exportData.addEventListener('click', exportData);
els.importData.addEventListener('click', importData);
els.exportCompact?.addEventListener('click', exportCompactData);
els.exportSnapshot?.addEventListener('click', exportSnapshotData);
els.exportAssetBundle?.addEventListener('click', exportAssetBundleData);
els.resetAll.addEventListener('click', resetAll);
els.validateWorld?.addEventListener('click', validateWorld);
els.exportModerationReport?.addEventListener('click', exportModerationReport);
els.clearReports?.addEventListener('click', clearModerationReports);
els.showHiddenAssets?.addEventListener('click', () => {
els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden;
renderHiddenAssets();
});
}
function resizeCanvas() {
dpr = window.devicePixelRatio || 1;
cw = Math.max(1, window.innerWidth);
ch = Math.max(1, window.innerHeight);
els.canvas.width = Math.floor(cw * dpr);
els.canvas.height = Math.floor(ch * dpr);
els.canvas.style.width = `${cw}px`;
els.canvas.style.height = `${ch}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
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) {
els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name));
els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`));
}
function setMode(nextMode) {
mode = nextMode;
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', 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.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.',
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 : {}
};
return target;
}
function isAssetTombstoned(assetId, version = 0) {
const tombstone = state.tombstones?.assets?.[assetId];
return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0));
}
function isObjectTombstoned(objectId, version = 0) {
const tombstone = state.tombstones?.objects?.[objectId];
return Boolean(tombstone && Number(tombstone.version || 0) >= Number(version || 0));
}
function getObjectOwner(kind, object) {
if (!object) return '';
return normalizeOwnerAccountId(object.ownerAccountId, findAsset(object.assetId)?.ownerAccountId || '');
}
function canCurrentAccountModifyObject(kind, object, showToast = true) {
if (!isSharedWorld()) return true;
const actor = currentAccountId();
if (!actor) {
if (showToast) toast('Shared worlds require an account before changing island objects.');
return false;
}
const owner = getObjectOwner(kind, object);
if (owner && owner === actor) return true;
if (showToast) toast('Only the owner can move or delete this island object.');
return false;
}
function canCurrentAccountDeleteAsset(asset, showToast = true) {
// Temporary measure: allow deleting any work so the collection can be cleaned up quickly.
// Revert by setting TEMP_ALLOW_DELETE_ALL_WORKS back to false above.
if (TEMP_ALLOW_DELETE_ALL_WORKS) return true;
if (!isSharedWorld()) return true;
const actor = currentAccountId();
if (!actor) {
if (showToast) toast('Shared worlds require an account before deleting assets.');
return false;
}
const owner = normalizeOwnerAccountId(asset?.ownerAccountId);
if (owner && owner === actor) return true;
if (showToast) toast('Only the owner can delete this asset in a shared world.');
return false;
}
function makeSharedCommand(type, payload = {}) {
return {
id: `cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
type,
actorAccountId: currentAccountId(),
createdAt: Date.now(),
...payload
};
}
function queueSharedCommand(command) {
if (!command) return;
ensureWorldProtectionState();
state.serverSync.pendingCommands.push(command);
state.serverSync.pendingCommands = state.serverSync.pendingCommands.slice(-300);
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();
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×16 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;
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 (isSharedWorld()) {
if (!canCurrentAccountModifyObject('static', staticTarget)) return;
queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId: staticTarget.id }));
return;
}
const index = state.placed.findIndex((p) => p.id === staticTarget.id);
if (index >= 0) {
const item = state.placed[index];
const asset = findAsset(item.assetId);
state.placed.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id));
if (selectedObject?.id === item.id) selectedObject = null;
saveState();
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
const dynamicObjects = getDynamicHomesAtTile(x, y);
const dynamicTarget = dynamicObjects.at(-1);
if (dynamicTarget) {
if (isSharedWorld()) {
if (!canCurrentAccountModifyObject('dynamic', dynamicTarget)) return;
queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId: dynamicTarget.id }));
return;
}
const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id);
if (index >= 0) {
const item = state.dynamicSummons[index];
const asset = findAsset(item.assetId);
state.dynamicSummons.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id));
if (selectedObject?.id === item.id) selectedObject = null;
hydrateRuntime();
saveState();
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
return;
}
}
toast('Nothing to erase here.');
}
function eraseObject(kind, objectId) {
if (kind === 'static') {
const index = state.placed.findIndex((p) => p.id === objectId);
if (index >= 0) {
if (isSharedWorld()) {
if (!canCurrentAccountModifyObject('static', state.placed[index])) return;
queueSharedCommand(makeSharedCommand('object.delete', { kind: 'static', objectId }));
return;
}
const asset = findAsset(state.placed[index].assetId);
state.placed.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId));
if (selectedObject?.id === objectId) selectedObject = null;
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
if (kind === 'dynamic') {
const index = state.dynamicSummons.findIndex((p) => p.id === objectId);
if (index >= 0) {
if (isSharedWorld()) {
if (!canCurrentAccountModifyObject('dynamic', state.dynamicSummons[index])) return;
queueSharedCommand(makeSharedCommand('object.delete', { kind: 'dynamic', objectId }));
return;
}
const asset = findAsset(state.dynamicSummons[index].assetId);
state.dynamicSummons.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId));
if (selectedObject?.id === objectId) selectedObject = null;
hydrateRuntime();
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
}
}
}
function usesRightButtonAsPaint() {
return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase';
}
function onPaintPointerDown(event) {
event.preventDefault();
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 exportEditorPng() {
const scale = Math.max(8, Math.floor(512 / Math.max(editorWidth, editorHeight)));
const canvas = document.createElement('canvas');
canvas.width = editorWidth * scale;
canvas.height = editorHeight * scale;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
const pixels = getDisplayEditorPixels();
for (let y = 0; y < editorHeight; y++) {
for (let x = 0; x < editorWidth; x++) {
const code = pixels[y * editorSize + x];
if (!code) continue;
c.fillStyle = colorToHex(code);
c.fillRect(x * scale, y * scale, scale, scale);
}
}
const link = document.createElement('a');
const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art';
link.download = `${rawName}-${editorWidth}x${editorHeight}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
toast('PNG exported.');
}
function importEditorPng(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const img = new Image();
img.onload = () => {
const target = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height);
commitEditorMutation(() => {
const previousSize = editorSize;
const canvas = document.createElement('canvas');
canvas.width = target.width;
canvas.height = target.height;
const c = canvas.getContext('2d', { willReadFrequently: true });
c.imageSmoothingEnabled = false;
c.clearRect(0, 0, target.width, target.height);
c.drawImage(img, 0, 0, target.width, target.height);
const data = c.getImageData(0, 0, target.width, target.height).data;
const next = blankPixels(target.width, target.height);
for (let i = 0; i < target.width * target.height; i++) {
const alpha = data[i * 4 + 3];
if (alpha < 32) continue;
const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]);
next[i] = nearestPaletteCode(hex);
}
editorWidth = target.width;
editorHeight = target.height;
editorSize = Math.max(target.width, target.height);
updateDimensionInputs();
setDisplayEditorPixels(next);
depthPixels = Array(editorSize * editorSize).fill(0);
lightPixels = [];
particlePixels = [];
doorPixel = { x: Math.min(doorPixel.x, target.width - 1), y: Math.min(doorPixel.y, target.height - 1) };
editorSelection = null;
if (previousSize !== editorSize) resetEditorView();
return true;
});
URL.revokeObjectURL(img.src);
const note = (img.naturalWidth === target.width && img.naturalHeight === target.height) ? '' : ` (scaled from ${img.naturalWidth}×${img.naturalHeight})`;
toast(`PNG imported as ${target.width}×${target.height}${note}.`);
};
img.onerror = () => toast('Could not read that PNG.');
img.src = URL.createObjectURL(file);
}
function chooseImportCanvasSize(width, height) {
const w = Math.max(1, Math.round(Number(width) || 1));
const h = Math.max(1, Math.round(Number(height) || 1));
const scale = Math.min(1, MAX_EDITOR_DIMENSION / Math.max(w, h));
return {
width: clampDimension(Math.round(w * scale), Math.min(w, MAX_EDITOR_DIMENSION)),
height: clampDimension(Math.round(h * scale), Math.min(h, MAX_EDITOR_DIMENSION))
};
}
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);
}
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 = 'Save + Place 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 = '';
const visibleAssets = state.assets.filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id]);
if (!visibleAssets.length) {
els.assetList.textContent = 'No visible assets.';
renderHiddenAssets();
return;
}
const myName = (state.authorName || 'Local Artist').trim() || 'Local Artist';
const myAccountId = currentAccountId();
const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter;
const isMyAsset = (asset) => isSharedWorld()
? normalizeOwnerAccountId(asset.ownerAccountId) === myAccountId
: (asset.author || 'Local Artist') === myName;
const mine = visibleAssets.filter((asset) => isMyAsset(asset) && matchesFilter(asset));
const others = visibleAssets.filter((asset) => !isMyAsset(asset) && matchesFilter(asset));
addLibrarySection('My works', mine);
addLibrarySection('Others', others);
renderHiddenAssets();
renderLikedCodex();
}
function addLibrarySection(title, assets) {
const heading = document.createElement('button');
heading.type = 'button';
heading.className = 'librarySectionTitle';
heading.textContent = `${title} · ${libraryFilterLabel()}`;
heading.title = 'Click to filter by genre';
heading.addEventListener('click', () => {
cycleLibraryFilter();
renderLibrary();
});
els.assetList.append(heading);
if (!assets.length) {
const empty = document.createElement('div');
empty.className = 'libraryEmptyNote';
empty.textContent = 'No assets in this genre.';
els.assetList.append(empty);
return;
}
const grid = document.createElement('div');
grid.className = 'assetSectionGrid';
for (const asset of assets) grid.append(makeAssetCard(asset));
els.assetList.append(grid);
}
function libraryFilterLabel() {
return libraryFilter === 'all' ? 'All' : cap(libraryFilter);
}
function cycleLibraryFilter() {
const filters = ['all', 'human', 'animal', 'nature', 'building', 'ship', 'other'];
const index = filters.indexOf(libraryFilter);
libraryFilter = filters[(index + 1) % filters.length];
toast(`Collection filter: ${libraryFilterLabel()}`);
}
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);
card.addEventListener('click', () => {
selectedAssetId = expanded ? null : asset.id;
updateSelectedLabel();
renderLibrary();
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: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
meta.append(span1, span2);
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
const up = makeButton(`${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
const down = makeButton(`${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
down.classList.toggle('mutedVote', assetPreviousVote > 0);
const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
const isMine = isSharedWorld()
? normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId()
: (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
const remix = makeButton('Remix', (event) => { event?.stopPropagation?.(); remixEdit(asset); });
const edit = isMine ? makeButton('Edit', (event) => { event?.stopPropagation?.(); editOriginalAsset(asset); }) : null;
const move = isMine ? makeButton('Place/Move', (event) => {
event?.stopPropagation?.();
selectedAssetId = asset.id;
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');
const canDeleteAny = TEMP_ALLOW_DELETE_ALL_WORKS || isMine;
actions.append(up, down, hide, remix);
if (edit) actions.append(edit);
if (move) actions.append(move);
if (canDeleteAny) actions.append(del);
meta.append(actions);
} else {
const mini = document.createElement('span');
mini.textContent = `${cap(asset.subtype)} · ${assetWidth(asset)}×${assetHeight(asset)}`;
meta.append(mini);
}
card.append(preview, meta);
return card;
}
function renderLikedCodex() {
if (!els.likedCodex) return;
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);
}
}
const liked = [...likedIds].map(findAsset).filter(Boolean).slice(0, 12);
if (!liked.length) {
els.likedCodex.innerHTML = '<div class="likedCodexTitle">Favorite</div><div class="hint">Works you upvote appear here.</div>';
return;
}
els.likedCodex.innerHTML = '<div class="likedCodexTitle">Favorite</div><div class="likedCodexList"></div>';
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 = `<strong></strong><span></span><span></span>`;
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 = `<strong></strong><span>Author: ${asset.author || 'Local Artist'}</span>`;
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 = '';
for (const entry of PALETTE) {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = `paletteSwatch${entry.code === selectedColorCode ? ' active' : ''}`;
swatch.dataset.code = entry.code;
swatch.title = `${entry.code} ${entry.color}`;
swatch.style.background = entry.color;
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 deleteAsset(asset) {
if (!asset) return;
if (isSharedWorld()) {
if (!canCurrentAccountDeleteAsset(asset)) return;
queueSharedCommand(makeSharedCommand('asset.delete', { assetId: asset.id }));
return;
}
const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id);
const usageText = used ? ' Island placements that use it will also be removed.' : '';
if (!confirm(`Delete “${asset.name}”?${usageText}`)) return;
const removedObjectIds = new Set();
for (const placed of state.placed || []) if (placed.assetId === asset.id) removedObjectIds.add(placed.id);
for (const summon of state.dynamicSummons || []) if (summon.assetId === asset.id) removedObjectIds.add(summon.id);
state.assets = state.assets.filter((a) => a.id !== asset.id);
state.placed = state.placed.filter((p) => p.assetId !== asset.id);
state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id);
delete state.assetVotes?.[asset.id];
delete state.hiddenAssets?.[asset.id];
for (const id of removedObjectIds) {
delete state.objectVotes?.[id];
delete state.hiddenObjects?.[id];
}
state.moderationReports = (state.moderationReports || []).filter((report) => !removedObjectIds.has(report.objectId));
if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null;
if (selectedObject && (selectedObject.assetId === asset.id || removedObjectIds.has(selectedObject.id))) selectedObject = null;
recordSyncEvent(Phase2Sync?.createAssetDeleteEvent?.(asset.id));
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
updateSelectionBubble(performance.now());
toast(`${asset.name} 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);
scheduleFrame();
}
function tick(time) {
animationFrameId = 0;
if (document.hidden) {
lastRuntimeUpdate = time;
return;
}
const elapsed = Math.min(100, Math.max(0, time - lastRuntimeUpdate));
lastRuntimeUpdate = time;
dynamicLogicRemainder += elapsed;
let steps = 0;
while (dynamicLogicRemainder >= DYNAMIC_LOGIC_STEP_MS && steps < MAX_DYNAMIC_STEPS_PER_FRAME) {
updateDynamicRuntime(DYNAMIC_LOGIC_STEP_MS / 1000, time);
dynamicLogicRemainder -= DYNAMIC_LOGIC_STEP_MS;
steps++;
}
if (steps >= MAX_DYNAMIC_STEPS_PER_FRAME) dynamicLogicRemainder = 0;
updateClock();
render(time);
scheduleFrame();
}
function updateClock() {
const phase = getPhase();
const 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;
drawTerrainCache(terrainCache);
lastRenderedSpriteInfo.clear();
const lightSources = [];
const visibleItems = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN));
const activeLights = collectLightSourcesForItems(visibleItems, time, phase);
if (lightSources && activeLights.length) lightSources.push(...activeLights);
if (beginProjectedShadowLayer(phase)) {
drawTerrainProjectedShadows(phase);
drawObjectProjectedShadows(visibleItems, time, phase);
endProjectedShadowLayer(phase);
} else {
drawTerrainProjectedShadows(phase);
drawObjectProjectedShadows(visibleItems, time, phase);
}
drawHoverTile();
drawObjectsFromItems(visibleItems, time, activeLights, phase, false);
if (visualSettings().enableParticles) {
spawnConfiguredParticleEmitters(visibleItems, time);
drawNatureDriftParticles(time);
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 = 'lighter';
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 drawObjectProjectedShadows(items, time, phase) {
for (const item of items) {
if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') continue;
drawSpriteShadow(getSpriteDrawInfo(item, time, true), phase);
}
}
function getSpriteDrawInfo(item, time, includeSelectBounce = true) {
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 selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 560, 0, 1);
if (selectedT < 1) {
const bounce = Math.sin(selectedT * Math.PI);
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;
// Selection landing squash/stretch: keep it tied to the visible landing moment.
// The previous delayed timer placed the squash after the bounce had already ended,
// so it looked like the aspect-ratio animation had disappeared.
const land = Math.exp(-Math.pow((selectedT - 0.88) / 0.085, 2));
stretchX += land * 0.30;
stretchY -= land * 0.21;
}
}
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++) {
if (!pixels[y * aw + x]) continue;
const depth = depths[y * aw + x] || 0;
if (!depth) continue;
let rr = 0, gg = 0, bb = 0, aa = 0;
for (const { source, local } of sources) {
const lc = parseHex(source.color || '#ffd86a');
if (!lc) continue;
const cellPx = (x + 0.5) * scale;
const cellPy = (y + 0.5) * scale;
const d = Math.hypot(local.x - cellPx, local.y - cellPy);
const reach = Math.max(scale * 2.2, source.radius * 3.8);
const t = 1 - clamp(d / reach, 0, 1);
if (t <= 0) continue;
const edge = getDepthLightFacing(depth, x, y, aw, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5, ah);
const depthFactor = depth > 0 ? 0.98 + edge * 0.42 : 0.74 + edge * 0.22;
const sourceIntensity = clamp(Number(source.intensity ?? 1) || 1, 0.25, 1.75);
// Make weak light subtle and strong light visibly snap on, instead of a flat linear ramp.
const exponential = (Math.exp(3.15 * t * sourceIntensity) - 1) / (Math.exp(3.15 * sourceIntensity) - 1);
const amount = exponential * depthFactor * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER;
rr += lc.r * amount;
gg += lc.g * amount;
bb += lc.b * amount;
aa += amount;
}
if (aa <= 0.002) continue;
const inv = 1 / aa;
const alpha = Math.min(0.86, 0.10 + Math.pow(aa, 1.05) * 0.42);
octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${alpha.toFixed(3)})`;
octx.fillRect(x * scale, y * scale, scale, scale);
if (aa > 0.42 && scale >= 4) {
octx.fillStyle = `rgba(${Math.round(rr * inv)}, ${Math.round(gg * inv)}, ${Math.round(bb * inv)}, ${(alpha * 0.34).toFixed(3)})`;
octx.fillRect(x * scale + 1, y * scale + 1, Math.max(1, scale - 2), Math.max(1, scale - 2));
}
}
}
ctx.save();
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'lighter';
if (info.angle) {
ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8));
ctx.rotate(info.angle);
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 ${asset.author || 'Local Artist'}`;
const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null;
const remixSource = findRemixSourceObject(asset);
if (els.bubbleRemixFrom) {
els.bubbleRemixFrom.hidden = !parent;
els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : '';
}
if (els.bubbleTeleport) {
els.bubbleTeleport.hidden = !remixSource;
els.bubbleTeleport.textContent = remixSource?.asset ? `Teleport to ${remixSource.asset.name || 'source'}` : 'Teleport to source';
}
if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`;
const votes = getObjectVoteCounts(objectId);
const voter = currentVoterKey();
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) {
const mine = isSharedWorld()
? normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId()
: (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
els.bubbleEdit.hidden = !mine;
}
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 = isSharedWorld()
? normalizeOwnerAccountId(asset.ownerAccountId) === currentAccountId()
: (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
if (!mine) {
toast('Only your own assets can be edited directly. Use Remix instead.');
return;
}
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;
}
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) {
if (tile.type === 'highland') drawHighlandWallFaces(c, tile, worldData);
drawTerrainTile(c, tile, worldData);
}
c.restore();
chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy });
minX = Math.min(minX, bounds.x);
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 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) {
const { x, y } = tileToWorld(tile.x, tile.y);
const lift = getTileLift(tile);
const frontLeft = worldData.get(tile.x, tile.y + 1);
const frontRight = worldData.get(tile.x + 1, tile.y);
const leftLower = !frontLeft || frontLeft.type !== 'highland';
const rightLower = !frontRight || frontRight.type !== 'highland';
if (rightLower) {
c.fillStyle = '#7f8b66';
c.beginPath();
c.moveTo(x, y + TILE_H - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
c.lineTo(x, y + TILE_H);
c.closePath();
c.fill();
}
if (leftLower) {
c.fillStyle = '#6f7d5d';
c.beginPath();
c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x, y + TILE_H);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
c.closePath();
c.fill();
}
}
function getTileLift(tile) {
return tile?.type === 'highland' ? 12 : 0;
}
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 mergeDefaultGallery(normalizeState(expanded));
}
}
} catch (error) {
console.warn('Could not load local state.', error);
}
return seedState();
}
function saveState() {
state.schema = SAVE_SCHEMA;
ensureWorldProtectionState();
canonicalizeAssetStorage();
state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) };
state.moderationReports = normalizeModerationReports(state.moderationReports || []);
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [];
const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
cachePhase2State();
updateSyncStats();
updateGuardrailStats();
updateRotationStats();
}
function dedupeNormalizedAssets(assets) {
const byId = new Map();
const exact = new Set();
const out = [];
for (const asset of assets || []) {
if (!asset?.id) continue;
if (byId.has(asset.id)) continue;
const key = [asset.contentHash || '', asset.name || '', asset.category || '', asset.subtype || '', asset.width || asset.size || '', asset.height || asset.size || '', normalizeOwnerAccountId(asset.ownerAccountId)].join('|');
if (asset.contentHash && exact.has(key)) continue;
byId.set(asset.id, asset);
if (asset.contentHash) exact.add(key);
out.push(asset);
}
return out;
}
function normalizeState(input) {
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: {} }
};
ensureWorldProtectionState(normalized);
const ownerByAssetId = new Map(normalized.assets.map((asset) => [asset.id, normalizeOwnerAccountId(asset.ownerAccountId)]));
normalized.placed.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); });
normalized.dynamicSummons.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); });
normalized.assets = normalized.assets.filter((asset) => !normalized.tombstones.assets?.[asset.id]);
normalized.placed = normalized.placed.filter((item) => !normalized.tombstones.objects?.[item.id]);
normalized.dynamicSummons = normalized.dynamicSummons.filter((item) => !normalized.tombstones.objects?.[item.id]);
return normalized;
}
function canonicalizeAssetStorage() {
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))
: [];
return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, width, legacyParticlePixels, sourcePixels, height);
}
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 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 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 removedLowQualitySeedAssetIds = new Set([...REMOVED_LOW_QUALITY_SEED_ASSET_NAMES].map((name) => idByName[name]).filter(Boolean));
const applySeedQualityFilter = (gallery) => ({
...gallery,
assets: gallery.assets.filter((asset) => !removedLowQualitySeedAssetIds.has(asset.id)),
placed: gallery.placed.filter((object) => !removedLowQualitySeedAssetIds.has(object.assetId)),
dynamicSummons: gallery.dynamicSummons.filter((object) => !removedLowQualitySeedAssetIds.has(object.assetId))
});
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 : [];
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]));
for (const seededAsset of seeded.assets) {
const existing = assetByName.get(seededAsset.name);
if (!existing) {
baseState.assets.push(seededAsset);
assetByName.set(seededAsset.name, seededAsset);
assetIdByName.set(seededAsset.name, seededAsset.id);
continue;
}
const isDefaultAsset = existing.ownerAccountId === 'island-team' || existing.author === 'Island Team';
if (isDefaultAsset) {
const existingId = existing.id;
const createdAt = existing.createdAt || seededAsset.createdAt;
Object.assign(existing, {
...seededAsset,
id: existingId,
createdAt,
updatedAt: Date.now(),
ownerAccountId: 'island-team',
author: 'Island Team'
});
existing.contentHash = computeAssetContentHash(existing);
assetIdByName.set(seededAsset.name, existingId);
}
}
const seededNameById = new Map(seeded.assets.map((asset) => [asset.id, asset.name]));
const hasPlacedAsset = new Set(baseState.placed.filter((item) => item && item.ownerAccountId === 'island-team').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 && item.ownerAccountId === 'island-team').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: {} },
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: 'Island Team',
ownerAccountId: 'island-team',
version: 1,
meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, w, meta.particleConfig || meta.particlePixels || [], right, h)
};
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 exportData() {
els.dataBox.value = JSON.stringify(state, null, 2);
toast('Full JSON exported.');
}
function exportCompactData() {
const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
els.dataBox.value = JSON.stringify(payload);
toast('Compact JSON exported.');
}
function exportSnapshotData() {
if (!Phase2Sync?.makeSnapshot) {
exportData();
return;
}
els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state));
toast('Snapshot JSON exported.');
}
function exportAssetBundleData() {
if (!Phase2Sync?.makeAssetBundle) {
exportData();
return;
}
els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id)));
toast('Asset bundle JSON exported.');
}
function prepareImportedAssetForSharedWorld(asset) {
const actor = currentAccountId();
const existing = state.assets.find((item) => item.id === asset.id);
const existingOwner = normalizeOwnerAccountId(existing?.ownerAccountId);
if (existing && existingOwner && existingOwner !== actor) {
return {
...asset,
id: uid(),
ownerAccountId: actor,
author: state.authorName || actor || 'Local Artist',
parentAssetId: asset.parentAssetId || asset.id,
originalAssetId: asset.originalAssetId || asset.id,
createdAt: Date.now(),
updatedAt: Date.now(),
version: 1
};
}
return {
...asset,
ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, actor),
author: asset.author || state.authorName || actor || 'Local Artist',
version: Number(asset.version) || 1
};
}
function importData() {
try {
const imported = JSON.parse(els.dataBox.value);
if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) {
toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.');
return;
}
if (Phase2Sync?.isAssetBundle?.(imported)) {
if (isSharedWorld()) ensureLocalAccount('import');
const importedAssets = Phase2Sync.unpackAssetBundle(imported)
.map(normalizeAsset)
.map((asset) => isSharedWorld() ? normalizeAsset(prepareImportedAssetForSharedWorld(asset)) : asset)
.filter((asset) => !isAssetTombstoned(asset.id, asset.version));
const byId = new Map(state.assets.map((asset) => [asset.id, asset]));
importedAssets.forEach((asset) => byId.set(asset.id, asset));
state.assets = [...byId.values()];
rebuildWorldIndex();
saveState();
spriteCache.clear();
renderLibrary();
updateSelectedLabel();
toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`);
return;
}
if (isSharedWorld()) {
toast('Shared worlds block full-state imports. Import an asset bundle or use a local sandbox.');
return;
}
const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported;
state = normalizeState(expanded);
rebuildWorldIndex();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('JSON imported.');
} catch (error) {
toast('Invalid JSON.');
console.error(error);
}
}
function resetAll() {
if (!confirm('Reset all local assets and island objects?')) return;
localStorage.removeItem(STORAGE_KEY);
state = seedState();
rebuildWorldIndex();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('Local world reset.');
}
function recordSyncEvent(event) {
if (!event) return;
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 collectWorldValidation() {
const assetIds = new Set((state.assets || []).map((asset) => asset.id));
const contentCounts = new Map();
for (const asset of state.assets || []) {
const key = asset.contentHash || computeAssetContentHash(asset);
contentCounts.set(key, (contentCounts.get(key) || 0) + 1);
}
const orphanStatic = (state.placed || []).filter((item) => !assetIds.has(item.assetId));
const orphanDynamic = (state.dynamicSummons || []).filter((item) => !assetIds.has(item.assetId));
const invalidTerrain = [];
for (const item of state.placed || []) {
const asset = findAsset(item.assetId);
const tile = world.get(item.x, item.y);
if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'static', id: item.id, assetId: item.assetId, x: item.x, y: item.y });
}
for (const item of state.dynamicSummons || []) {
const asset = findAsset(item.assetId);
const tile = world.get(Math.round(item.homeX), Math.round(item.homeY));
if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'dynamic', id: item.id, assetId: item.assetId, x: item.homeX, y: item.homeY });
}
const duplicateContent = [...contentCounts.values()].filter((count) => count > 1).reduce((sum, count) => sum + count - 1, 0);
return {
schema: SAVE_SCHEMA,
generatedAt: new Date().toISOString(),
assets: state.assets?.length || 0,
worldObjects: getWorldObjectCount(),
hiddenObjects: Object.keys(state.hiddenObjects || {}).length,
reports: state.moderationReports?.length || 0,
duplicateContent,
orphanStatic: orphanStatic.length,
orphanDynamic: orphanDynamic.length,
invalidTerrain: invalidTerrain.length,
invalidTerrainObjects: invalidTerrain,
limits: state.guardrails || PHASE5_GUARDRAILS
};
}
function isTerrainCompatible(asset, tile) {
if (!asset || !tile) return false;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water';
if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water';
return tile.type !== 'water';
}
function updateGuardrailStats() {
if (!els.guardrailStats) return;
const report = collectWorldValidation();
const assetText = `${report.assets}/${report.limits.maxAssets}`;
const objectText = `${report.worldObjects}/${report.limits.maxWorldObjects}`;
const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain;
els.guardrailStats.textContent = `Assets ${assetText}, objects ${objectText}, reports ${report.reports}, hidden ${report.hiddenObjects}, duplicate-content copies ${report.duplicateContent}, validation warnings ${warnings}.`;
}
function validateWorld() {
const report = collectWorldValidation();
els.dataBox.value = JSON.stringify({ format: 'pixel-island-phase5-validation-v1', ...report }, null, 2);
updateGuardrailStats();
const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain;
toast(warnings ? `Validation found ${warnings} warning(s).` : 'Validation passed.');
}
function exportModerationReport() {
const validation = collectWorldValidation();
const payload = {
format: 'pixel-island-phase5-moderation-v1',
generatedAt: validation.generatedAt,
authorName: state.authorName || 'Local Artist',
reports: normalizeModerationReports(state.moderationReports || []),
hiddenObjects: state.hiddenObjects || {},
downvotedObjects: Object.entries(state.objectVotes || {})
.map(([objectId, votes]) => ({ objectId, up: votes.up || 0, down: votes.down || 0, score: (votes.up || 0) - (votes.down || 0) }))
.filter((item) => item.down > 0),
validation
};
els.dataBox.value = JSON.stringify(payload, null, 2);
toast('Moderation report exported.');
}
function clearModerationReports() {
if (!state.moderationReports?.length) {
toast('No local reports to clear.');
return;
}
if (!confirm('Clear local moderation reports? Hidden objects stay hidden.')) return;
state.moderationReports = [];
saveState();
updateGuardrailStats();
toast('Local reports cleared.');
}
function updateSyncStats() {
if (!els.syncStats) return;
const report = Phase2Sync?.compactSizeReport?.(state);
if (!report) {
els.syncStats.textContent = 'Phase 2 codec unavailable.';
return;
}
const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet';
const statsRect = getViewportWorldRect(64);
const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0;
els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}, pending commands ${state.serverSync?.pendingCommands?.length || 0}. Authority: publish ${serverAuthority().publish}, move ${serverAuthority().objectMove}, day/night ${serverAuthority().dayNight}, dynamic ${serverAuthority().dynamicMotion}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`;
}
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) {
if (!color || typeof color !== 'string') return null;
if (PALETTE_BY_CODE[color]) return color;
const rgb = parseHex(color);
if (!rgb) return selectedColorCode;
let best = PALETTE[0].code;
let bestDist = Infinity;
for (const entry of PALETTE) {
const p = parseHex(entry.color);
const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2;
if (dist < bestDist) { bestDist = dist; best = entry.code; }
}
return best;
}
function readableTextColor(hex) {
const rgb = parseHex(hex);
if (!rgb) return '#243044';
const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
return yiq > 140 ? '#243044' : '#fffdf5';
}
function parseHex(hex) {
if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
const clean = hex.replace('#', '');
const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
const value = parseInt(full, 16);
if (!Number.isFinite(value)) return null;
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
}
function normalizeDepthPixels(input, 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 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) : 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 depthResponse = depth ? (isAssetLight ? assetResponse : 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)
: (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) : (0.12 + depthResponse * 0.22))
: t * t;
const strength = strengthBase * (isAssetLight ? (10.5 + sourceHotspot * 18 + Math.abs(depth) * 1.2) : (4.2 + Math.abs(depth) * 2.6)) * edgePresence * sourceIntensity * localDepthMultiplier;
const mix = Math.min(depth ? (isAssetLight ? 0.055 : 0.48) : (isAssetLight ? 0.06 : 0.24), (0.008 + response * (depth ? (isAssetLight ? 0.012 : 0.075) : (isAssetLight ? 0.028 : 0.10))) * sourceIntensity * (depth ? (isAssetLight ? 0.7 : 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 : 0.045) : (isAssetLight ? 0.006 : 0.03)) * sourceIntensity * edgePresence * (depth ? (isAssetLight ? sourceHotspot : (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 : (0.12 + depthResponse * 0.18)) : Math.pow(t, 1.25);
const tintPower = tintBase * sourceIntensity * (depth ? (isAssetLight ? 0.016 : 0.12) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER : (isAssetLight ? 0.012 : 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) : (0.12 + depthResponse * 0.16)) : Math.pow(t, 1.9);
localLift += liftBase * sourceIntensity * (depth ? (isAssetLight ? 0.85 : 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 distanceResponse = depthLocalLightResponse(d, reach);
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, 1.85);
const nearSource = Math.pow(clamp(1 - d / Math.max(1.2, reach * 0.18), 0, 1), 2.3);
const plateau = 0.74 + distanceResponse * 0.34;
const edgeFocus = 0.4 + facing * 0.95;
const edgeStrength = outerEdge ? 1.08 : 0.94;
const cornerBoost = 1 + cornerStrength * (0.9 + facing * 0.38);
const sourceBoost = 1 + nearSource * 1.12;
total += plateau * edgeFocus * edgeStrength * cornerBoost * sourceBoost * sourceIntensity * (depth > 0 ? 8.2 : 6.4) * DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER;
}
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() {
// 62 stable palette slots. The first 48 entries are arranged as four
// top-to-bottom ramps in the 4-column picker: neutral, warm, green/cyan, blue/purple.
// The remaining slots are accents; this avoids the old zig-zag gradient.
const colors = [
'#fffdf7', '#fff0c7', '#e9fff2', '#eef4ff',
'#eee5d4', '#ffdca0', '#bff6d2', '#cbdcff',
'#d4c8b8', '#ffc071', '#87e79f', '#99b5f3',
'#b8a58f', '#ee9a55', '#57cf78', '#6f8fe2',
'#95806b', '#d6723d', '#35ad5a', '#4f68c5',
'#75614f', '#ad4f2b', '#248443', '#36469a',
'#5a4d43', '#84351f', '#1a6435', '#27306e',
'#454048', '#5e2418', '#124827', '#1b214d',
'#31333d', '#3f1712', '#0b311c', '#111733',
'#222632', '#2a0e0b', '#071f13', '#0a0d20',
'#151923', '#160806', '#04140c', '#050812',
'#090b10', '#090403', '#010804', '#01030a',
'#fff2f5', '#ffe55d', '#aef8ff', '#ead0ff',
'#ffb0c7', '#f2c438', '#60d6e8', '#c58df0',
'#ef6b88', '#e88c3e', '#37b9c9', '#9a56d9',
'#cf263f', '#c55a1c', '#1f7fa2', '#7132ad',
'#18c33a', '#76d322', '#22c78e', '#e247ae',
'#1e79e8', '#8148db'
];
return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] || '#14151c' }));
}
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();
})();