10318 lines
420 KiB
JavaScript
10318 lines
420 KiB
JavaScript
(() => {
|
||
'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();
|
||
})();
|