pixel/app.js
2026-06-02 16:26:44 +09:00

5731 lines
236 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

(() => {
'use strict';
console.info('Pixel Island Summoner loaded');
const STORAGE_KEY = 'pixel-island-summoner:phase5e';
const SAVE_SCHEMA = 13;
const WORLD_W = 144;
const WORLD_H = 112;
const TILE_W = 32;
const TILE_H = 16;
const ORIGIN_X = WORLD_H * TILE_W / 2 + 80;
const ORIGIN_Y = 42;
const DAY_MS = 10 * 60 * 1000;
const STATIC_SCALE = 2;
const DYNAMIC_SCALE = 1;
const MAX_ZOOM = 3.2;
const MIN_ZOOM = 0.45;
const TERRAIN_CHUNK_SIZE = 16;
const VIEW_CULL_MARGIN = 192;
const DYNAMIC_LOGIC_STEP_MS = 1000 / 15;
const MAX_DYNAMIC_STEPS_PER_FRAME = 3;
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const MODULES = window.PixelIslandModules || {};
const EditorActions = MODULES.EditorActions || null;
const Phase2Sync = MODULES.Phase2Sync || null;
const EDITOR_HISTORY_LIMIT = 80;
const PHASE5_GUARDRAILS = { maxAssets: 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 $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
const mod = (n, m) => ((n % m) + m) % m;
const lerp = (a, b, t) => a + (b - a) * t;
function defaultVisualSettings() {
return { enableLights: true, enableParticles: true, enableDayNight: true, localDisplayLimit: PHASE5_GUARDRAILS.defaultDisplayLimit };
}
function visualSettings() {
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
return state.settings;
}
function hydrateVisualSettingsUI() {
const settings = visualSettings();
if (els.settingLights) els.settingLights.checked = settings.enableLights !== false;
if (els.settingParticles) els.settingParticles.checked = settings.enableParticles !== false;
if (els.settingDayNight) els.settingDayNight.checked = settings.enableDayNight !== false;
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' ? 'dynamic' : 'static';
}
function roleToSubtype(role) {
return role || 'other';
}
function subtypeToRole(asset) {
const subtype = asset?.subtype;
if (['human', 'animal', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype;
if (subtype === 'water') return 'other';
if (asset?.category === 'dynamic') return 'animal';
return 'other';
}
const els = {
canvas: $('worldCanvas'),
openEditor: $('openEditor'),
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'),
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 hoverTile = null;
let dynamicRuntime = [];
let spriteCache = new Map();
let lastRuntimeUpdate = performance.now();
let lastClockSecond = -1;
let toastTimer = null;
let editorSize = 8;
let editorPixels = blankPixels(8);
let editorLeftPixels = blankPixels(8);
let editingSide = 'right';
let paintTool = 'brush';
let selectedColorCode = 'a';
let advancedDraw = false;
let depthPixels = blankPixels(8).map(() => 0);
let depthPaintMode = 1;
let isPainting = false;
let staticKind = 'nature';
let dynamicKind = 'human';
let lightPixels = [];
let 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 animationFrameId = 0;
let dynamicLogicRemainder = 0;
let worldIndex = makeEmptyWorldIndex();
let selectedObject = null;
let pendingReport = null;
let libraryFilter = 'all';
let renderPhase = null;
let editorView = { zoom: 1, x: 0, y: 0 };
let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 };
let editorHistory = [];
let editorFuture = [];
let editorGestureSnapshot = null;
let editorGestureChanged = false;
let suppressNextPaintClick = false;
let suppressMousePaintUntil = 0;
let editorSelection = null;
let selectionGesture = null;
let shapeGesture = null;
let shapePreview = null;
let placementPreview = null;
function bootstrap() {
resizeCanvas();
resetView(false);
hydrateRuntime();
coastalFoamTextures = buildCoastalFoamTextures();
wireUI();
renderPalette();
hydrateAuthorUI();
updateAccountUI();
refreshCategoryUI();
setupEditor(8, blankPixels(8), null);
clearEditorHistory();
renderLibrary();
updateSelectedLabel();
rebuildWorldIndex();
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 oldSize = editorSize;
const nextSize = Number(els.assetSize.value);
commitEditorMutation(() => {
const resizedRight = resizePixels(editorPixels, oldSize, nextSize);
const resizedDepth = resizeDepthPixels(depthPixels, oldSize, nextSize);
setupEditor(nextSize, resizedRight, null, resizedDepth);
doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) };
lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize);
particlePixels = particlePixels.filter((p) => p.x < nextSize && p.y < nextSize);
resetEditorView();
return true;
});
updateSettingsSummary();
});
els.assetCategory.addEventListener('change', refreshCategoryUI);
document.querySelectorAll('[data-static-kind]').forEach((button) => {
button.addEventListener('click', () => {
staticKind = button.dataset.staticKind;
document.querySelectorAll('[data-static-kind]').forEach((b) => b.classList.toggle('active', b === button));
refreshCategoryUI();
});
});
document.querySelectorAll('[data-dynamic-kind]').forEach((button) => {
button.addEventListener('click', () => {
dynamicKind = button.dataset.dynamicKind;
document.querySelectorAll('[data-dynamic-kind]').forEach((b) => b.classList.toggle('active', b === button));
refreshCategoryUI();
});
});
els.editRight.addEventListener('click', () => setEditingSide('right'));
els.editLeft.addEventListener('click', () => setEditingSide('left'));
els.toolBrush.addEventListener('click', () => setPaintTool('brush'));
els.toolErase.addEventListener('click', () => setPaintTool('erase'));
els.toolFill?.addEventListener('click', () => setPaintTool('fill'));
els.toolPick?.addEventListener('click', () => setPaintTool('pick'));
els.toolLine?.addEventListener('click', () => setPaintTool('line'));
els.toolRect?.addEventListener('click', () => setPaintTool('rect'));
els.toolSelect?.addEventListener('click', () => setPaintTool('select'));
els.toolLight.addEventListener('click', () => setPaintTool('light'));
els.toolDoor.addEventListener('click', () => setPaintTool('door'));
els.toolDepth?.addEventListener('click', () => setPaintTool('depth'));
els.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);
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 = mirrorPixels(editorPixels, editorSize);
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.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);
}
function resetView(announce) {
const bounds = terrainCache.bounds;
view.zoom = Math.min(1.05, Math.max(MIN_ZOOM, Math.min(cw / bounds.w, ch / bounds.h) * 1.18));
view.x = cw / 2 - (bounds.x + bounds.w / 2) * view.zoom;
view.y = ch / 2 - (bounds.y + bounds.h / 2) * view.zoom + 34;
if (announce) toast('View reset.');
}
function setDrawerOpen(open) {
els.drawer.classList.toggle('open', open);
els.openEditor.hidden = open;
if (!open) {
selectedObject = null;
updateSelectionBubble(performance.now());
}
}
function onDocumentContextMenu(event) {
if (!els.drawer?.classList.contains('open')) return;
if (els.drawer.contains(event.target) || els.openEditor?.contains(event.target) || 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);
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.',
nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.',
building: 'Buildings attract humans. Use Door to mark the entrance.',
ship: 'Ships are static water objects. They float and emit ring ripples.',
other: 'Other objects are neutral scenery and render at 2× scale.'
};
els.roleHint.textContent = textMap[role] || textMap.other;
}
function updateSettingsSummary() {
if (!els.settingsSummary) return;
const role = currentRole();
if (roleToCategory(role) === 'static') {
const lightCount = lightPixels.length;
const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light';
const 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)} / 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)} / ${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');
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();
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
};
els.canvas.classList.add('dragging');
}
function onWorldPointerMove(event) {
const pos = getCanvasPoint(event);
hoverTile = screenToTile(pos.x, pos.y);
updateTileInfo();
if (!pointer.down || pointer.id !== event.pointerId) return;
const dx = event.clientX - pointer.lastX;
const dy = event.clientY - pointer.lastY;
const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY);
if (total > 3) pointer.dragging = true;
if (pointer.dragging) {
view.x += dx;
view.y += dy;
}
pointer.lastX = event.clientX;
pointer.lastY = event.clientY;
}
function onWorldPointerUp(event) {
if (!pointer.down || pointer.id !== event.pointerId) return;
els.canvas.classList.remove('dragging');
const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY);
const elapsed = performance.now() - pointer.downTime;
const wasClick = total < 6 && elapsed < 600;
const pos = getCanvasPoint(event);
const tile = screenToTile(pos.x, pos.y);
if (wasClick) {
const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now());
if (mode === 'place' && tile) placeSelected(tile.x, tile.y);
else if (mode === 'erase') {
if (pickedObject) eraseObject(pickedObject.kind, pickedObject.id);
else if (tile) eraseAt(tile.x, tile.y);
} else if (pickedObject) {
selectWorldObject(pickedObject.kind, pickedObject.id, pickedObject.assetId, performance.now());
const asset = findAsset(pickedObject.assetId);
toast(asset ? `Selected ${asset.name}.` : 'Selected object.');
} else if (tile) inspectAt(tile.x, tile.y);
}
pointer.down = false;
pointer.id = null;
pointer.dragging = false;
}
function onWorldWheel(event) {
event.preventDefault();
const pos = getCanvasPoint(event);
const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13;
zoomAt(pos.x, pos.y, view.zoom * factor);
}
function zoomAt(screenX, screenY, nextZoom) {
nextZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
const worldX = (screenX - view.x) / view.zoom;
const worldY = (screenY - view.y) / view.zoom;
view.zoom = nextZoom;
view.x = screenX - worldX * view.zoom;
view.y = screenY - worldY * view.zoom;
}
function getCanvasPoint(event) {
const rect = els.canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function screenToTile(screenX, screenY) {
const worldX = (screenX - view.x) / view.zoom;
const worldY = (screenY - view.y) / view.zoom;
const localX = worldX - ORIGIN_X;
const localY = worldY - ORIGIN_Y;
const a = localX / (TILE_W / 2);
const b = localY / (TILE_H / 2);
const baseX = Math.floor((a + b) / 2);
const baseY = Math.floor((b - a) / 2);
let best = null;
let bestScore = -Infinity;
for (let ty = baseY - 2; ty <= baseY + 2; ty++) {
for (let tx = baseX - 2; tx <= baseX + 2; tx++) {
if (tx < 0 || ty < 0 || tx >= WORLD_W || ty >= WORLD_H) continue;
const tile = world.get(tx, ty);
const center = tileToWorld(tx, ty);
const lift = getTileLift(tile);
const nx = Math.abs(worldX - center.x) / (TILE_W / 2);
const ny = Math.abs(worldY - (center.y + TILE_H / 2 - lift)) / (TILE_H / 2);
if (nx + ny <= 1) {
const score = lift * 100 + (tx + ty);
if (score > bestScore) {
bestScore = score;
best = { x: tx, y: ty, tile };
}
}
}
}
return best;
}
function tileToWorld(x, y) {
return {
x: (x - y) * TILE_W / 2 + ORIGIN_X,
y: (x + y) * TILE_H / 2 + ORIGIN_Y
};
}
function tileKey(x, y) {
return `${x},${y}`;
}
function makeEmptyWorldIndex() {
return { placedByTile: new Map(), dynamicByHomeTile: new Map() };
}
function pushIndexBucket(map, key, item) {
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
function rebuildWorldIndex() {
const next = makeEmptyWorldIndex();
for (const placed of state.placed || []) {
pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed);
}
for (const summon of state.dynamicSummons || []) {
pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon);
}
worldIndex = next;
}
function getPlacedAtTile(x, y) {
return worldIndex.placedByTile.get(tileKey(x, y)) || [];
}
function getDynamicHomesAtTile(x, y) {
return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || [];
}
function getLocalDisplayLimit() {
const settings = visualSettings();
return clampInt(settings.localDisplayLimit, 25, 500, PHASE5_GUARDRAILS.defaultDisplayLimit);
}
function getObjectRecord(kind, objectId) {
if (kind === 'static') return state.placed.find((p) => p.id === objectId) || null;
return state.dynamicSummons.find((p) => p.id === objectId) || null;
}
function getObjectPublicAt(kind, object) {
return 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 allObjects = [...(state.placed || []), ...(state.dynamicSummons || [])];
return allObjects.some((object) => object.assetId === asset.id && isPermanentlyHiddenObject(object) && object.hiddenReason === 'moderation_violation');
}
function isServerSuppressedObject(object) {
return ['archived', 'permanent_hidden', 'violation_hidden'].includes(object?.status);
}
function getObjectRotationEntry(kind, object, baseIndex = 0) {
const objectId = object?.id;
const votes = getObjectVoteCounts(objectId);
const rawUp = Number(votes.up) || 0;
const up = Math.min(rawUp, PHASE5_GUARDRAILS.upvoteRankCap);
const down = Number(votes.down) || 0;
const delay = up * PHASE5_GUARDRAILS.upvoteDelaySlots;
const advance = down * PHASE5_GUARDRAILS.downvoteAdvanceSlots;
return {
kind,
object,
id: objectId,
assetId: object?.assetId,
publicAt: getObjectPublicAt(kind, object),
baseIndex,
up,
rawUp,
down,
effectiveSlot: baseIndex + delay - advance
};
}
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 = '') {
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();
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;
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.`);
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);
}
function shouldHideUnderPlacementPreview(assetId, x, y) {
return false;
}
function getDrawableItems(time = performance.now(), viewport = null) {
const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN);
const items = [];
for (const placed of state.placed) {
const asset = findAsset(placed.assetId);
if (!asset || 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) {
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 });
}
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 = asset.size * scale + 96;
const h = asset.size * scale + 128;
return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom;
}
function pickObjectAtScreen(screenX, screenY, time = performance.now()) {
const worldX = (screenX - view.x) / view.zoom;
const worldY = (screenY - view.y) / view.zoom;
const items = getDrawableItems(time, getViewportWorldRect(256));
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
const info = getSpriteDrawInfo(item, time, false);
if (isSpritePixelHit(item.asset, info, worldX, worldY)) {
return { kind: item.kind, id: item.source.id, assetId: item.asset.id };
}
}
return null;
}
function isSpritePixelHit(asset, info, worldX, worldY) {
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
let localX;
let localY;
if (info.angle) {
const pivotX = info.drawX + info.sprite.width / 2;
const pivotY = info.drawY + info.sprite.height * 0.8;
const dx = worldX - pivotX;
const dy = worldY - pivotY;
const cos = Math.cos(-info.angle);
const sin = Math.sin(-info.angle);
localX = dx * cos - dy * sin + info.sprite.width / 2;
localY = dx * sin + dy * cos + info.sprite.height * 0.8;
} else {
localX = worldX - info.drawX;
localY = worldY - info.drawY;
}
if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false;
// Tiny 8×8 works are hard to click. Use the whole sprite box for them,
// including transparent cells, while larger works still use opaque pixels.
if ((asset.size || 0) <= 8) return true;
const px = Math.floor(localX / scale);
const py = Math.floor(localY / scale);
if (px < 0 || py < 0 || px >= asset.size || py >= asset.size) return false;
const pixels = getAssetPixels(asset, info.side || 'right');
return Boolean(pixels[py * asset.size + px]);
}
function updateTileInfo() {
if (!els.tileInfo) return;
if (!hoverTile) {
els.tileInfo.textContent = 'Move over the map.';
return;
}
const tile = world.get(hoverTile.x, hoverTile.y);
const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).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 };
if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true;
selectedAssetId = assetId;
updateSelectedLabel();
renderLibrary();
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;
}
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, 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, 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) {
const index = state.placed.findIndex((p) => p.id === staticTarget.id);
if (index >= 0) {
const item = state.placed[index];
const asset = findAsset(item.assetId);
state.placed.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id));
if (selectedObject?.id === item.id) selectedObject = null;
saveState();
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
const dynamicObjects = getDynamicHomesAtTile(x, y);
const dynamicTarget = dynamicObjects.at(-1);
if (dynamicTarget) {
const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id);
if (index >= 0) {
const item = state.dynamicSummons[index];
const asset = findAsset(item.assetId);
state.dynamicSummons.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id));
if (selectedObject?.id === item.id) selectedObject = null;
hydrateRuntime();
saveState();
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
return;
}
}
toast('Nothing to erase here.');
}
function eraseObject(kind, objectId) {
if (kind === 'static') {
const index = state.placed.findIndex((p) => p.id === objectId);
if (index >= 0) {
const asset = findAsset(state.placed[index].assetId);
state.placed.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId));
if (selectedObject?.id === objectId) selectedObject = null;
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
if (kind === 'dynamic') {
const index = state.dynamicSummons.findIndex((p) => p.id === objectId);
if (index >= 0) {
const asset = findAsset(state.dynamicSummons[index].assetId);
state.dynamicSummons.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId));
if (selectedObject?.id === objectId) selectedObject = null;
hydrateRuntime();
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
}
}
}
function usesRightButtonAsPaint() {
return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase';
}
function onPaintPointerDown(event) {
event.preventDefault();
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 >= editorSize || y >= editorSize) return;
const key = `${x},${y},${paintTool},${button},${shiftKey}`;
if (key === lastPaintedKey && paintTool !== 'fill') return;
lastPaintedKey = key;
const point = displayCellToCanonical(x, y);
const index = point.y * editorSize + point.x;
let changed = false;
if (paintTool === 'pick') {
const picked = editorPixels[index];
if (picked) selectPaletteCode(picked);
setPaintTool('brush');
drawEditor();
return;
}
if (paintTool === 'fill') {
const displayPixels = getDisplayEditorPixels();
const fillColor = shiftKey ? null : selectedColorCode;
const nextDisplay = EditorActions?.floodFill
? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor)
: fallbackFloodFill(displayPixels, editorSize, x, y, fillColor);
if (nextDisplay.changed) {
editorPixels = nextDisplay.pixels;
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
if (fillColor === null && Array.isArray(nextDisplay.cells)) {
for (const cell of nextDisplay.cells) {
const src = displayCellToCanonical(cell.x, cell.y);
removeLightPixel(src.x, src.y);
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 = mirrorPixels(editorPixels, editorSize);
markEditorChanged();
drawEditor();
}
}
function paintEventToCell(event) {
const local = paintEventToLocal(event);
return { x: Math.floor(local.x), y: Math.floor(local.y) };
}
function paintEventToLocal(event) {
return clientToPaintLocal(event.clientX, event.clientY);
}
function clientToPaintLocal(clientX, clientY) {
const rect = els.paintCanvas.getBoundingClientRect();
const scaleX = els.paintCanvas.width / rect.width;
const scaleY = els.paintCanvas.height / rect.height;
const sx = (clientX - rect.left) * scaleX;
const sy = (clientY - rect.top) * scaleY;
const cell = els.paintCanvas.width / editorSize;
return {
x: (sx - editorView.x) / editorView.zoom / cell,
y: (sy - editorView.y) / editorView.zoom / cell
};
}
function snapshotEditorState() {
return {
size: editorSize,
rightPixels: [...editorPixels],
depthPixels: [...depthPixels],
lightPixels: lightPixels.map((p) => ({ ...p })),
particlePixels: particlePixels.map((p) => ({ ...p })),
particleConfig: { ...particleConfig },
doorPixel: { ...doorPixel },
editingSide,
selectedColorCode,
editorSelection: editorSelection ? { ...editorSelection } : null
};
}
function restoreEditorState(snapshot) {
editorSize = snapshot.size || editorSize;
editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize);
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize);
lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : [];
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(editorSize / 2), y: editorSize - 1 };
editingSide = snapshot.editingSide || editingSide;
if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode;
editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null;
els.assetSize.value = String(editorSize);
updateSideButtons();
renderPalette();
drawEditor();
updateSettingsSummary();
updateEditorHistoryButtons();
updateEditorSelectionButtons();
}
function beginEditorGesture() {
if (!editorGestureSnapshot) {
editorGestureSnapshot = snapshotEditorState();
editorGestureChanged = false;
}
}
function markEditorChanged() {
editorGestureChanged = true;
}
function finishEditorGesture() {
if (!editorGestureSnapshot) return;
if (editorGestureChanged) {
editorHistory.push(editorGestureSnapshot);
if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift();
editorFuture = [];
}
editorGestureSnapshot = null;
editorGestureChanged = false;
updateEditorHistoryButtons();
}
function commitEditorMutation(mutator) {
const before = snapshotEditorState();
const changed = Boolean(mutator());
if (changed) {
editorHistory.push(before);
if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift();
editorFuture = [];
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
drawEditor();
updateSettingsSummary();
updateEditorHistoryButtons();
}
return changed;
}
function clearEditorHistory() {
editorHistory = [];
editorFuture = [];
editorGestureSnapshot = null;
editorGestureChanged = false;
updateEditorHistoryButtons();
}
function undoEditor() {
if (!editorHistory.length) return;
editorFuture.push(snapshotEditorState());
restoreEditorState(editorHistory.pop());
}
function redoEditor() {
if (!editorFuture.length) return;
editorHistory.push(snapshotEditorState());
restoreEditorState(editorFuture.pop());
}
function updateEditorHistoryButtons() {
if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0;
if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0;
updateEditorSelectionButtons();
}
function onEditorKeyDown(event) {
if (!els.drawer?.classList.contains('open')) return;
const key = event.key.toLowerCase();
const activeTag = document.activeElement?.tagName?.toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return;
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
if (event.shiftKey) redoEditor();
else undoEditor();
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
redoEditor();
} else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) {
event.preventDefault();
const step = event.shiftKey ? 4 : 1;
const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key];
moveSelectionBy(delta[0], delta[1]);
} else if (key === 'escape') {
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, size, x, y, colorCode) {
const pixels = [...sourcePixels];
const target = pixels[y * size + x] || null;
const replacement = colorCode || null;
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
const stack = [[x, y]];
const cells = [];
while (stack.length) {
const [cx, cy] = stack.pop();
if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue;
const index = cy * size + cx;
if ((pixels[index] || null) !== target) continue;
pixels[index] = replacement;
cells.push({ x: cx, y: cy });
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
}
return { pixels, changed: cells.length > 0, count: cells.length, cells };
}
function handleEditorSpecialPointerDown(event) {
const cell = clientToCell(event.clientX, event.clientY);
if (!cell) return false;
if (paintTool === 'line' || paintTool === 'rect') {
beginEditorGesture();
shapeGesture = {
tool: paintTool,
startX: cell.x,
startY: cell.y,
endX: cell.x,
endY: cell.y,
erase: event.button === 2,
filled: event.shiftKey
};
shapePreview = { ...shapeGesture };
drawEditor();
return true;
}
if (paintTool === 'select') {
const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection);
if (inside) {
beginEditorGesture();
const baseSelection = { ...editorSelection };
selectionGesture = {
mode: 'move',
startX: cell.x,
startY: cell.y,
lastDx: 0,
lastDy: 0,
baseSelection,
basePixels: getDisplayEditorPixels(),
baseDepthPixels: getDisplayDepthPixels(),
baseLightPixels: lightPixels.map((p) => ({ ...p })),
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 = mirrorPixels(editorPixels, editorSize);
updateEditorSelectionButtons();
return;
}
}
function clientToCell(clientX, clientY) {
const local = clientToPaintLocal(clientX, clientY);
const x = Math.floor(local.x);
const y = Math.floor(local.y);
if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return null;
return { x, y };
}
function normalizeSelectionRect(x0, y0, x1, y1) {
const left = clamp(Math.min(x0, x1), 0, editorSize - 1);
const top = clamp(Math.min(y0, y1), 0, editorSize - 1);
const right = clamp(Math.max(x0, x1), 0, editorSize - 1);
const bottom = clamp(Math.max(y0, y1), 0, editorSize - 1);
return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 };
}
function pointInSelection(x, y, selection) {
return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h;
}
function clampSelectionDelta(selection, dx, dy) {
if (!selection) return { dx: 0, dy: 0 };
return {
dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)),
dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h))
};
}
function getDisplayDepthPixels() {
return normalizeDepthPixels(depthPixels, editorSize);
}
function setDisplayEditorPixels(pixels) {
editorPixels = normalizePixels(pixels, editorSize);
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
}
function setDisplayDepthPixels(pixels) {
depthPixels = normalizeDepthPixels(pixels, editorSize);
}
function mirrorScalarPixels(values, size) {
const source = Array.isArray(values) ? values : [];
const out = Array(size * size).fill(0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
out[y * size + (size - 1 - x)] = source[y * size + x] || 0;
}
}
return out;
}
function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, 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 >= editorSize || ny >= editorSize) 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: editorSize, h: editorSize };
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, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) };
return true;
});
}
function commitShapeGesture(gesture) {
const cells = gesture.tool === 'line'
? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY)
: getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled);
if (!cells.length) return false;
const pixels = getDisplayEditorPixels();
const value = gesture.erase ? null : selectedColorCode;
let changed = false;
for (const cell of cells) {
const index = cell.y * editorSize + cell.x;
if ((pixels[index] || null) !== value) {
pixels[index] = value;
changed = true;
}
if (gesture.erase) {
const canonical = displayCellToCanonical(cell.x, cell.y);
removeLightPixel(canonical.x, canonical.y);
}
}
if (!changed) return false;
setDisplayEditorPixels(pixels);
return true;
}
function getLineCells(x0, y0, x1, y1) {
const cells = [];
let dx = Math.abs(x1 - x0);
let dy = -Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
let x = x0;
let y = y0;
while (true) {
cells.push({ x, y });
if (x === x1 && y === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; }
if (e2 <= dx) { err += dx; y += sy; }
}
return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize);
}
function getRectCells(x0, y0, x1, y1, filled = false) {
const rect = normalizeSelectionRect(x0, y0, x1, y1);
const cells = [];
for (let y = rect.y; y < rect.y + rect.h; y++) {
for (let x = rect.x; x < rect.x + rect.w; x++) {
if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y });
}
}
return cells;
}
function drawEditorOverlays(cell) {
if (shapePreview) {
const cells = shapePreview.tool === 'line'
? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY)
: getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled);
pctx.save();
pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42;
pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode);
for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell));
pctx.restore();
}
if (editorSelection) {
pctx.save();
pctx.strokeStyle = '#ff4fa3';
pctx.lineWidth = Math.max(2, 2 / editorView.zoom);
pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]);
pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom);
pctx.fillStyle = 'rgba(255, 79, 163, .08)';
pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell);
pctx.restore();
}
}
function updateEditorSelectionButtons() {
const disabled = !editorSelection;
if (els.clearSelection) els.clearSelection.disabled = disabled;
}
function 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;
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 = mirrorPixels(editorPixels, editorSize);
depthPixels = mirrorScalarPixels(depthPixels, editorSize);
lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x }));
particlePixels = particlePixels.map((p) => ({ ...p, x: editorSize - 1 - p.x }));
doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x };
editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null;
return true;
});
}
function flipEditorVertical() {
commitEditorMutation(() => {
editorPixels = flipPixelsVertical(editorPixels, editorSize);
depthPixels = flipScalarPixelsVertical(depthPixels, editorSize);
lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y }));
particlePixels = particlePixels.map((p) => ({ ...p, y: editorSize - 1 - p.y }));
doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y };
editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null;
return true;
});
}
function flipPixelsVertical(pixels, size) {
const source = normalizePixels(pixels, size);
const out = blankPixels(size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || null;
}
return out;
}
function flipScalarPixelsVertical(values, size) {
const source = normalizeDepthPixels(values, size);
const out = Array(size * size).fill(0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || 0;
}
return out;
}
function applyEditorOutline() {
commitEditorMutation(() => {
const source = [...editorPixels];
const out = [...editorPixels];
let changed = false;
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const index = y * editorSize + x;
if (source[index]) continue;
const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => {
const nx = x + dx;
const ny = y + dy;
return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source[ny * editorSize + nx];
});
if (adjacent) { out[index] = selectedColorCode; changed = true; }
}
}
if (!changed) return false;
editorPixels = out;
return true;
});
}
function exportEditorPng() {
const scale = Math.max(8, Math.floor(512 / editorSize));
const canvas = document.createElement('canvas');
canvas.width = editorSize * scale;
canvas.height = editorSize * scale;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
const pixels = getDisplayEditorPixels();
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const code = pixels[y * editorSize + x];
if (!code) continue;
c.fillStyle = colorToHex(code);
c.fillRect(x * scale, y * scale, scale, scale);
}
}
const link = document.createElement('a');
const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art';
link.download = `${rawName}-${editorSize}x${editorSize}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
toast('PNG exported.');
}
function importEditorPng(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const img = new Image();
img.onload = () => {
const targetSize = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height);
commitEditorMutation(() => {
const previousSize = editorSize;
const canvas = document.createElement('canvas');
canvas.width = targetSize;
canvas.height = targetSize;
const c = canvas.getContext('2d', { willReadFrequently: true });
c.imageSmoothingEnabled = false;
c.clearRect(0, 0, targetSize, targetSize);
const fit = fitImageInsideSquare(img.naturalWidth || img.width, img.naturalHeight || img.height, targetSize);
c.drawImage(img, fit.dx, fit.dy, fit.dw, fit.dh);
const data = c.getImageData(0, 0, targetSize, targetSize).data;
const next = blankPixels(targetSize);
for (let i = 0; i < targetSize * targetSize; i++) {
const alpha = data[i * 4 + 3];
if (alpha < 32) continue;
const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]);
next[i] = nearestPaletteCode(hex);
}
editorSize = targetSize;
els.assetSize.value = String(targetSize);
setDisplayEditorPixels(next);
depthPixels = blankPixels(targetSize).map(() => 0);
lightPixels = [];
doorPixel = { x: Math.min(doorPixel.x, targetSize - 1), y: Math.min(doorPixel.y, targetSize - 1) };
editorSelection = null;
if (previousSize !== targetSize) resetEditorView();
return true;
});
URL.revokeObjectURL(img.src);
const note = (img.naturalWidth === targetSize && img.naturalHeight === targetSize) ? '' : ` (fit from ${img.naturalWidth}×${img.naturalHeight})`;
toast(`PNG imported as ${targetSize}×${targetSize}${note}.`);
};
img.onerror = () => toast('Could not read that PNG.');
img.src = URL.createObjectURL(file);
}
function chooseImportCanvasSize(width, height) {
const allowed = [8, 16, 32, 64];
const w = Math.max(1, Math.round(Number(width) || 1));
const h = Math.max(1, Math.round(Number(height) || 1));
if (w === h && allowed.includes(w)) return w;
const target = Math.max(w, h);
return allowed.find((size) => target <= size) || allowed[allowed.length - 1];
}
function fitImageInsideSquare(width, height, size) {
const w = Math.max(1, Number(width) || 1);
const h = Math.max(1, Number(height) || 1);
const scale = Math.min(size / w, size / h);
const dw = Math.max(1, Math.round(w * scale));
const dh = Math.max(1, Math.round(h * scale));
return {
dx: Math.floor((size - dw) / 2),
dy: Math.floor((size - dh) / 2),
dw,
dh
};
}
function rgbToHex(r, g, b) {
const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0');
return `#${part(r)}${part(g)}${part(b)}`;
}
function selectPaletteCode(code) {
if (!PALETTE_BY_CODE[code]) return;
selectedColorCode = code;
els.paintColor.value = PALETTE_BY_CODE[code];
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 setupEditor(size, rightPixels, leftPixels = null, nextDepthPixels = null) {
editorSize = size;
const normalizedRight = normalizePixels(rightPixels, size);
const normalizedLeft = leftPixels ? normalizePixels(leftPixels, size) : null;
const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorPixels(normalizedLeft, size);
editorPixels = normalizePixels(canonical, size);
editorLeftPixels = mirrorPixels(editorPixels, size);
editorSelection = null;
depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size);
els.assetSize.value = String(size);
lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size);
particlePixels = particlePixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size);
particleConfig = normalizeParticleConfig(particleConfig);
doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) };
resetEditorView();
drawEditor();
updateEditorHistoryButtons();
}
function drawEditor() {
clampEditorView();
const canvas = els.paintCanvas;
const rectSize = canvas.width;
const cell = rectSize / editorSize;
pctx.clearRect(0, 0, rectSize, rectSize);
pctx.fillStyle = '#fffaf0';
pctx.fillRect(0, 0, rectSize, rectSize);
pctx.save();
pctx.translate(editorView.x, editorView.y);
pctx.scale(editorView.zoom, editorView.zoom);
const pixels = getDisplayEditorPixels();
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const color = pixels[y * editorSize + x];
if (!color) continue;
pctx.fillStyle = colorToHex(color);
pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell));
}
}
if (advancedDraw && depthPixels?.length) {
if (paintTool === 'depth') {
pctx.fillStyle = 'rgba(46, 89, 160, .07)';
pctx.fillRect(0, 0, rectSize, rectSize);
}
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const source = displayCellToCanonical(x, y);
const depth = depthPixels[source.y * editorSize + source.x] || 0;
if (!depth) continue;
const high = depth > 0;
pctx.fillStyle = high ? 'rgba(34, 116, 255, .62)' : 'rgba(88, 44, 120, .62)';
pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76));
pctx.strokeStyle = high ? 'rgba(6, 28, 75, .55)' : 'rgba(35, 13, 54, .55)';
pctx.lineWidth = Math.max(1, 2 / editorView.zoom);
pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76));
}
}
}
pctx.strokeStyle = 'rgba(36, 48, 68, .13)';
pctx.lineWidth = 1 / editorView.zoom;
for (let i = 0; i <= editorSize; i++) {
const p = Math.round(i * cell) + .5;
pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, rectSize); pctx.stroke();
pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(rectSize, p); pctx.stroke();
}
{
for (const light of lightPixels) {
const shown = canonicalCellToDisplay(light.x, light.y);
const lx = (shown.x + .5) * cell;
const ly = (shown.y + .5) * cell;
pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28);
pctx.beginPath();
pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2);
pctx.fill();
pctx.strokeStyle = colorToHex(light.c || selectedColorCode);
pctx.lineWidth = 2 / editorView.zoom;
pctx.beginPath();
pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2);
pctx.stroke();
}
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 scaled = size * editorView.zoom;
const minOffset = size - scaled;
editorView.x = clamp(editorView.x, minOffset, 0);
editorView.y = clamp(editorView.y, minOffset, 0);
}
function addLightPixel(x, y, colorCode = selectedColorCode) {
const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode };
const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y);
if (existing) existing.c = colorCode;
else lightPixels.push(point);
}
function removeLightPixel(x, y) {
lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y));
}
function addParticlePixel(x, y, colorCode = selectedColorCode, dir = particleConfig.dir || 'up') {
const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 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, size) {
return (Array.isArray(points) ? points : [])
.map((p) => ({ ...p, x: size - 1 - clampInt(p.x, 0, size - 1, 0) }))
.filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size);
}
function mirrorParticlePointsHorizontal(points, size) {
return (Array.isArray(points) ? points : [])
.map((p) => ({ ...p, x: size - 1 - clampInt(p.x, 0, size - 1, 0), dir: p.dir === 'left' ? 'right' : p.dir === 'right' ? 'left' : (p.dir || 'up') }))
.filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size);
}
function shiftParticlePointsVertical(points, size, shift = 0) {
return (Array.isArray(points) ? points : [])
.map((p) => ({ ...p, y: clampInt(p.y, 0, size - 1, 0) + shift }))
.filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size);
}
function normalizeEditorOrientationForSave(size) {
const dynamicLeftCanvas = roleToCategory(currentRole()) === 'dynamic' && editingSide === 'left';
const rightPixels = dynamicLeftCanvas ? mirrorPixels(editorPixels, size) : normalizePixels(editorPixels, size);
const normalizedDepth = normalizeDepthPixels(depthPixels, size);
const orientedDepth = dynamicLeftCanvas ? mirrorDepthPixels(normalizedDepth, size) : normalizedDepth;
const orientedLights = dynamicLeftCanvas ? mirrorLightPointsHorizontal(lightPixels, size) : lightPixels.map((p) => ({ ...p }));
const orientedParticles = dynamicLeftCanvas ? mirrorParticlePointsHorizontal(particlePixels, size) : particlePixels.map((p) => ({ ...p }));
const orientedParticleConfig = normalizeParticleConfig({ ...particleConfig, enabled: orientedParticles.length > 0 }, size);
return { rightPixels, depthPixels: orientedDepth, lightPixels: orientedLights, particlePixels: orientedParticles, particleConfig: orientedParticleConfig, mirroredFromLeft: dynamicLeftCanvas };
}
function alignEditorStateToBottom(size) {
const oriented = normalizeEditorOrientationForSave(size);
const shift = getBottomShift(oriented.rightPixels, size);
return {
rightPixels: shiftPixelsVertical(oriented.rightPixels, size, shift),
depthPixels: shiftDepthPixelsVertical(oriented.depthPixels, size, shift),
lightPixels: oriented.lightPixels
.map((p) => ({ ...p, y: p.y + shift }))
.filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size),
particlePixels: shiftParticlePointsVertical(oriented.particlePixels, size, shift),
particleConfig: normalizeParticleConfig({ ...oriented.particleConfig, enabled: oriented.particlePixels.length > 0 }, size),
door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) },
mirroredFromLeft: oriented.mirroredFromLeft
};
}
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 = null;
const paintedDots = countPixels(editorPixels);
if (paintedDots < 10) {
toast('Draw at least 10 pixels before saving.');
return null;
}
if (!existing && state.assets.length >= PHASE5_GUARDRAILS.maxAssets) {
toast(`Asset limit reached (${PHASE5_GUARDRAILS.maxAssets}). Delete or hide unused assets before saving more.`);
return null;
}
const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`;
const aligned = alignEditorStateToBottom(size);
const asset = {
id: existing?.id || uid(),
name,
category,
subtype,
size,
createdAt: existing?.createdAt || Date.now(),
updatedAt: Date.now(),
author: existing?.author || state.authorName || 'Local Artist',
parentAssetId: existing ? existing.parentAssetId : editParentId,
originalAssetId: existing ? existing.originalAssetId : editOriginalId,
pixels: encodePixels(aligned.rightPixels),
faces: category === 'dynamic' ? {
right: encodePixels(aligned.rightPixels),
left: 'mirror'
} : null,
meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels)
};
asset.contentHash = computeAssetContentHash(asset);
if (existing) {
const index = state.assets.findIndex((a) => a.id === existing.id);
if (index >= 0) state.assets[index] = asset;
toast(`${asset.name} updated.`);
} else {
state.assets.unshift(asset);
toast(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;
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(size);
const asset = {
id: `preview:${Date.now()}`,
name: (els.assetName.value || '').trim() || 'Preview work',
category, subtype, size, author: state.authorName || 'Local Artist',
pixels: encodePixels(aligned.rightPixels),
faces: category === 'dynamic' ? { right: encodePixels(aligned.rightPixels), left: 'mirror' } : null,
meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size, aligned.particlePixels)
};
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 = 'nature';
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 ID, name, and password. Change the password after creation.';
if (els.createAccount) els.createAccount.hidden = false;
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 quota = getPublishQuotaStatus();
const day = getAccountAgeMs() < 24 * 60 * 60 * 1000 ? 'first day' : 'day 2+';
els.accountNote.textContent = `Account ${day}; publish ${quota.used}/${quota.limit} this hour. Change the password from the generated default.`;
if (els.createAccount) els.createAccount.hidden = true;
}
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 matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter;
const mine = visibleAssets.filter((asset) => (asset.author || 'Local Artist') === myName && matchesFilter(asset));
const others = visibleAssets.filter((asset) => (asset.author || 'Local Artist') !== myName && matchesFilter(asset));
addLibrarySection('My works', mine);
addLibrarySection('Others', others);
renderHiddenAssets();
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');
card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`;
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
preview.width = 64;
preview.height = 64;
drawPreview(preview, asset);
const meta = document.createElement('div');
meta.className = 'assetMeta';
const lineage = asset.parentAssetId ? ' / derivative' : '';
meta.innerHTML = `<strong></strong><span>${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}</span><span></span>`;
meta.querySelector('strong').textContent = asset.name;
meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
card.addEventListener('click', () => {
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
focusAssetInWorld(asset);
});
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
const up = makeButton(`${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
const down = makeButton(`${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
down.classList.toggle('mutedVote', assetPreviousVote > 0);
const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
const isMine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
const remix = makeButton('Remix', (event) => {
event?.stopPropagation?.();
remixEdit(asset);
});
const move = isMine ? makeButton('Place/Move', (event) => {
event?.stopPropagation?.();
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
setMode('place');
setDrawerOpen(false);
toast('Click a valid tile to place or move it.');
}) : null;
const del = makeButton('Delete', (event) => {
event?.stopPropagation?.();
deleteAsset(asset);
});
del.classList.add('danger');
actions.append(up, down, hide, remix);
if (move) actions.append(move);
if (isMine) actions.append(del);
meta.append(actions);
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 scale = Math.floor(48 / asset.size) || 1;
const ox = Math.floor((canvas.width - asset.size * scale) / 2);
const oy = Math.floor((canvas.height - asset.size * scale) / 2);
for (let y = 0; y < asset.size; y++) {
for (let x = 0; x < asset.size; x++) {
const color = pixels[y * asset.size + x];
if (!color) continue;
c.fillStyle = colorToHex(color);
c.fillRect(ox + x * scale, oy + y * scale, scale, scale);
}
}
}
function renderPalette() {
if (!els.paletteGrid) return;
els.paletteGrid.innerHTML = '';
for (const entry of PALETTE) {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = `paletteSwatch${entry.code === selectedColorCode ? ' active' : ''}`;
swatch.dataset.code = entry.code;
swatch.title = `${entry.code} ${entry.color}`;
swatch.style.background = entry.color;
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 = false;
editingAssetId = null;
editParentId = asset.id;
editOriginalId = asset.originalAssetId || asset.id;
els.assetName.value = `${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' }));
particleConfig = normalizeParticleConfig((particlePixels.length ? { enabled: true, c: particlePixels[0].c || selectedColorCode, dir: particlePixels[0].dir || 'up' } : { enabled: false, c: selectedColorCode, dir: 'up' }), asset.size);
depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size);
doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 };
setupEditor(asset.size, right, null, depthPixels);
clearEditorHistory();
refreshCategoryUI();
els.lineageNote.textContent = `Remixing “${asset.name}”. Save creates a separate new collection work.`;
}
function editOriginalAsset(asset) {
remixEdit(asset);
}
function remixEdit(asset) {
loadAssetIntoEditor(asset, 'remix');
}
function copyEdit(asset) {
remixEdit(asset);
}
function deleteAsset(asset) {
if (!asset) 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() {
dynamicRuntime = state.dynamicSummons.map((summon) => {
const asset = findAsset(summon.assetId);
if (!asset) return null;
const homeTile = world.get(Math.round(summon.homeX), Math.round(summon.homeY));
const homeValid = homeTile && ((asset.subtype === 'fish') ? homeTile.type === 'water' : homeTile.type !== 'water');
const spawn = homeValid ? { x: Math.round(summon.homeX), y: Math.round(summon.homeY) } : findValidSpawn(asset, summon.homeX, summon.homeY, 6);
return {
id: summon.id,
assetId: summon.assetId,
homeX: summon.homeX,
homeY: summon.homeY,
x: spawn.x + .5,
y: spawn.y + .5,
targetX: spawn.x + .5,
targetY: spawn.y + .5,
vx: 0,
lastMoveX: 0,
facing: 1,
idleUntil: performance.now() + 700 + Math.random() * 1600,
hiddenUntil: 0,
seed: Math.random() * 9999,
nextDecisionAt: 0,
nextBubbleAt: 800 + Math.random() * 1500,
nextStepParticleAt: performance.now() + 300 + Math.random() * 280
};
}).filter(Boolean);
}
function findValidSpawn(asset, homeX, homeY, radius) {
if (asset.subtype === 'bird') {
const x = clamp(Math.round(homeX), 0, WORLD_W - 1);
const y = clamp(Math.round(homeY), 0, WORLD_H - 1);
if (world.get(x, y)?.type !== 'water') return { x, y };
}
const candidates = [];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = Math.round(homeX + dx);
const y = Math.round(homeY + dy);
if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) continue;
const tile = world.get(x, y);
if (asset.subtype === 'fish') {
if (tile.type === 'water') candidates.push({ x, y });
} else if (tile.type !== 'water') candidates.push({ x, y });
}
}
if (!candidates.length) return { x: clamp(Math.round(homeX), 0, WORLD_W - 1), y: clamp(Math.round(homeY), 0, WORLD_H - 1) };
return candidates[Math.floor(Math.random() * candidates.length)];
}
function updateDynamicRuntime(dt, time) {
bubbleParticles = bubbleParticles.filter((p) => time - p.started < p.life);
confettiParticles = confettiParticles.filter((p) => time - p.started < p.life);
landStepParticles = landStepParticles.filter((p) => time - p.started < p.life);
natureDriftParticles = natureDriftParticles.filter((p) => time - p.started < p.life);
for (const item of dynamicRuntime) {
const asset = findAsset(item.assetId);
if (!asset) continue;
if (time < item.hiddenUntil) continue;
if (visualSettings().enableParticles && asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) {
spawnFishBubbleCluster(item.x, item.y, time, item.seed);
item.nextBubbleAt = time + 1100 + Math.random() * 1800;
}
if (time < (item.idleUntil || 0)) {
item.vx = 0;
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(Date.now() / 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) {
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() {
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(Date.now(), DAY_MS);
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 / DAY_MS,
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;
for (const light of asset.meta.lightPixels) {
const lx = asset.category === 'dynamic' && info.side === 'left' ? asset.size - 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, asset.size / 64),
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 render(time = performance.now()) {
const phase = getPhase();
renderPhase = phase;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = phase.sky || '#86d5ff';
ctx.fillRect(0, 0, cw, ch);
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
ctx.imageSmoothingEnabled = false;
drawTerrainCache(terrainCache);
drawHoverTile();
const lightSources = [];
const visibleItems = drawObjects(time, lightSources, phase);
if (visualSettings().enableParticles) {
spawnConfiguredParticleEmitters(visibleItems, time);
drawNatureDriftParticles(time);
drawLandStepParticles(time);
drawBubbleParticles(time);
drawSpawnEffects(time);
drawConfettiParticles(time);
}
const nightLightsActive = areNightLightsActive(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);
}
function drawHoverTile() {
if (!hoverTile) return;
const { x, y } = tileToWorld(hoverTile.x, hoverTile.y);
const lift = getTileLift(hoverTile.tile);
ctx.beginPath();
ctx.moveTo(x, y - lift);
ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
ctx.lineTo(x, y + TILE_H - lift);
ctx.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
ctx.closePath();
ctx.strokeStyle = 'rgba(255, 255, 255, .7)';
ctx.lineWidth = 1 / view.zoom;
ctx.stroke();
}
function drawSpawnEffects(time) {
if (!spawnEffects.length) return;
spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650);
for (const effect of spawnEffects) {
const t = clamp((time - effect.started) / 650, 0, 1);
const pos = tileToWorld(effect.x, effect.y);
pos.y -= getLiftAtCoord(effect.x, effect.y);
const radius = 6 + t * 24;
ctx.save();
ctx.globalAlpha = 1 - t;
ctx.lineWidth = Math.max(1, 3 / view.zoom);
ctx.strokeStyle = '#ff79ad';
ctx.beginPath();
ctx.arc(pos.x, pos.y + TILE_H * .35, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#fff2a8';
for (let i = 0; i < 5; i++) {
const a = i * Math.PI * .4 + t * 2.4;
ctx.fillRect(pos.x + Math.cos(a) * radius - 2, pos.y + TILE_H * .35 + Math.sin(a) * radius - 2, 4, 4);
}
ctx.restore();
}
}
function drawObjects(time, lightSources, phase) {
const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN));
const activeLights = collectLightSourcesForItems(items, time, phase);
if (lightSources && activeLights.length) lightSources.push(...activeLights);
for (const item of items) {
if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') {
drawSpriteItem(item, time, activeLights, true, phase);
}
}
for (const item of items) {
if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) {
drawSpriteItem(item, time, activeLights, false, phase);
}
}
return items;
}
function getSpriteDrawInfo(item, time, includeSelectBounce = true) {
const asset = item.asset;
const pos = tileToWorld(item.x, item.y);
const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird');
if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y);
let bob = 0;
let alpha = 1;
let side = 'right';
let angle = 0;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6;
if (asset.category === 'dynamic') {
const runtime = item.source || {};
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;
}
}
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 * 8;
const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1;
const tiltSign = pseudoNoise(tiltSeed) > 0.5 ? 1 : -1;
angle += tiltSign * bounce * 0.18;
}
}
const sprite = getSpriteCanvas(asset, side);
const drawX = pos.x - sprite.width / 2;
const anchorY = asset.category === 'static' ? 0 : TILE_H / 2;
const drawY = pos.y + anchorY - sprite.height + bob;
return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY };
}
function drawSpriteItem(item, time, lightSources, underwater, phase) {
const info = getSpriteDrawInfo(item, time, true);
const { asset, pos, sprite, drawX, drawY, alpha, angle } = info;
if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id);
if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(info, phase);
ctx.save();
ctx.globalAlpha = alpha;
if (angle) {
ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8));
ctx.rotate(angle);
ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8));
} else {
ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY));
}
if (underwater) {
ctx.fillStyle = 'rgba(103, 181, 217, .18)';
if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height);
}
ctx.restore();
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 drawSpriteLightReflection(info, item, lightSources, phase) {
if (!areNightLightsActive(phase) || !lightSources?.length) return;
const asset = item.asset;
const ownerId = item.source?.id || asset.id;
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
const sprite = info.sprite;
const sources = [];
for (const source of lightSources) {
if (source.ownerId === ownerId) 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 pixels = getAssetPixels(asset, info.side || 'right');
const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
const depths = asset.category === 'dynamic' && info.side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : 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 < asset.size; y++) {
for (let x = 0; x < asset.size; x++) {
if (!pixels[y * asset.size + x]) continue;
const depth = depths[y * asset.size + x] || 0;
let rr = 0, gg = 0, bb = 0, aa = 0;
for (const { source, local } of sources.slice(0, 3)) {
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, asset.size, pixels, depths, local.x / scale - 0.5, local.y / scale - 0.5);
const depthFactor = depth > 0 ? 1.05 + edge * 0.38 : depth < 0 ? 0.72 + edge * 0.18 : 0.50 + edge * 0.10;
const amount = t * t * depthFactor;
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.34, 0.08 + aa * 0.18);
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);
}
}
ctx.save();
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
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 drawSurfaceLightBleed(items, lightSources, time) {
// Kept as a no-op compatibility stub. Reflections are now drawn per sprite
// immediately after that sprite, so light overlays preserve draw order.
}
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' ? item.asset.size - 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 = '') {
const seed = fnv1a(String(seedValue)).slice(0, 6);
const numericSeed = parseInt(seed, 16) || 1;
const cycleMs = 2300;
const cycle = Math.floor((time + numericSeed) / cycleMs);
if (pseudoNoise(cycle + numericSeed * 0.013) < 0.38) return;
const t = ((time + numericSeed) % cycleMs) / cycleMs;
const alpha = Math.max(0, 0.36 * (1 - t));
const rx = 5 + t * 22;
const ry = 2 + t * 8;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = '#e8fbff';
ctx.lineWidth = Math.max(1, 1.5 / view.zoom);
ctx.beginPath();
ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
function drawFishBubbles(x, y, time, seed) {
const cycle = Math.floor((time + seed * 37) / 1300);
if (pseudoNoise(cycle + seed) < 0.55) return;
const t = ((time + seed * 37) % 1300) / 1300;
ctx.save();
ctx.fillStyle = 'rgba(225, 252, 255, .58)';
ctx.globalAlpha = Math.max(0, 1 - t);
for (let i = 0; i < 4; i++) {
const ox = Math.round((pseudoNoise(cycle + seed + i * 4.1) - .5) * 7);
const oy = Math.round(-t * 12 - i * 3);
ctx.fillRect(Math.round(x + ox), Math.round(y + oy), 1, 1);
}
ctx.restore();
}
function drawSpriteShadow(info, phase) {
const { drawX, sprite, pos, anchorY, bob } = info;
const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 };
if ((shadow.alpha || 0) <= 0.001) return;
const silhouette = getShadowCanvas(sprite);
const contactX = drawX + sprite.width / 2;
// Keep the mirror line on the ground. When a selected sprite jumps upward,
// the reflected silhouette moves downward by the same amount instead of floating.
const groundY = pos.y + anchorY;
const mirrorOffset = Math.max(0, -(bob || 0));
ctx.save();
ctx.globalAlpha = shadow.alpha;
ctx.translate(contactX, groundY);
ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0);
ctx.drawImage(silhouette, Math.round(-sprite.width / 2), Math.round(mirrorOffset));
ctx.restore();
}
function getShadowCanvas(sprite) {
if (shadowCanvasCache.has(sprite)) return shadowCanvasCache.get(sprite);
const canvas = document.createElement('canvas');
canvas.width = sprite.width;
canvas.height = sprite.height;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
c.save();
c.translate(0, sprite.height);
c.scale(1, -1);
c.drawImage(sprite, 0, 0);
c.restore();
c.globalCompositeOperation = 'source-in';
c.fillStyle = '#1b1f26';
c.fillRect(0, 0, canvas.width, canvas.height);
shadowCanvasCache.set(sprite, canvas);
return canvas;
}
function currentVoterKey() {
return 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()) {
const item = getSelectedDrawableItem(time);
if (!item) return null;
const info = getSpriteDrawInfo(item, time, true);
const topPad = Math.max(14, Math.min(30, info.sprite.height * 0.18));
return {
x: info.drawX + info.sprite.width / 2,
y: info.drawY - topPad,
asset: item.asset,
objectId: item.source?.id || 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, (selected.asset.size || 8) * 2), asset: selected.asset, objectId: selected.objectId };
const { asset, objectId } = anchor;
const sx = anchor.x * view.zoom + view.x;
const sy = anchor.y * view.zoom + view.y;
if (sx < -120 || sy < -160 || sx > cw + 120 || sy > ch + 120) {
els.selectionBubble.hidden = true;
if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true;
return;
}
els.selectionBubble.hidden = false;
const bubbleX = clamp(sx, 96, cw - 96);
const bubbleY = clamp(sy, 28, ch - 118);
els.selectionBubble.style.left = `${Math.round(bubbleX)}px`;
els.selectionBubble.style.top = `${Math.round(bubbleY)}px`;
els.selectionBubble.style.transform = 'translate(-50%, -100%)';
els.bubbleName.textContent = asset.name || 'Untitled';
els.bubbleAuthor.textContent = `by ${asset.author || 'Local Artist'}`;
const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null;
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 = (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';
}
}
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 = (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 - selected.asset.size * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 25,
color: palette[i % palette.length],
started: time,
life: 850 + pseudoNoise(i + time) * 450
});
}
}
function drawConfettiParticles(time) {
if (!confettiParticles.length) return;
ctx.save();
for (const particle of confettiParticles) {
const ageMs = time - particle.started;
const t = ageMs / particle.life;
if (t < 0 || t > 1) continue;
const seconds = ageMs / 1000;
const x = particle.x + particle.vx * seconds;
const y = particle.y + particle.vy * seconds + 48 * seconds * seconds;
ctx.globalAlpha = 1 - t;
ctx.fillStyle = colorToHex(particle.color);
ctx.fillRect(Math.round(x), Math.round(y), 3, 3);
}
ctx.restore();
}
function drawLightSources(sources, darkness, 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 drawn after the night overlay and do not inherit night darkness.
ctx.globalCompositeOperation = 'source-over';
const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48));
core.addColorStop(0, hexToRgba(source.color, .48 * intensity));
core.addColorStop(.34, hexToRgba(source.color, .22 * 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.restore();
}
function getSpriteCanvas(asset, side) {
const lightsOn = areNightLightsActive(renderPhase);
const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}:${lightsOn ? 'lit' : 'unlit'}` : 'day:unlit';
const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`;
if (spriteCache.has(key)) return spriteCache.get(key);
const pixels = getAssetPixels(asset, side);
const rawDepths = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
const depths = asset.category === 'dynamic' && side === 'left' ? mirrorDepthPixels(rawDepths, asset.size) : rawDepths;
const lights = lightsOn ? getAssetLightPointsForSide(asset, side) : [];
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
const canvas = document.createElement('canvas');
canvas.width = asset.size * scale;
canvas.height = asset.size * scale;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
for (let y = 0; y < asset.size; y++) {
for (let x = 0; x < asset.size; x++) {
const color = pixels[y * asset.size + x];
if (!color) continue;
const depth = depths[y * asset.size + x] || 0;
c.fillStyle = shadeAssetPixelColor(colorToHex(color), depth, x, y, asset.size, renderPhase, pixels, depths, lights);
c.fillRect(x * scale, y * scale, scale, scale);
}
}
spriteCache.set(key, canvas);
return canvas;
}
function getAssetPixels(asset, side = 'right') {
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size);
return right;
}
function makeWorld() {
const islands = [
{ cx: 36, cy: 34, rx: 32, ry: 22, height: 1.04 },
{ cx: 92, cy: 30, rx: 34, ry: 20, height: .98 },
{ cx: 74, cy: 76, rx: 42, ry: 24, height: 1.02 },
{ cx: 124, cy: 72, rx: 16, ry: 14, height: .92 },
{ cx: 26, cy: 86, rx: 16, ry: 12, height: .9 },
{ cx: 118, cy: 102, rx: 20, ry: 9, height: .86 }
];
const tiles = [];
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
const continent = fractalPerlin(x * .035, y * .035, 4);
const detail = fractalPerlin(x * .095 + 41.7, y * .095 - 13.2, 3);
let score = -1.2;
for (const island of islands) {
const warpX = (fractalPerlin(x * .055 + island.cx, y * .055, 2) - .5) * 7.0;
const warpY = (fractalPerlin(x * .055, y * .055 + island.cy, 2) - .5) * 5.0;
const dx = (x + warpX - island.cx) / island.rx;
const dy = (y + warpY - island.cy) / island.ry;
const base = 1 - (dx * dx + dy * dy);
score = Math.max(score, base * island.height);
}
score += (continent - .5) * .36 + (detail - .5) * .18;
let type = 'water';
if (score > .015) type = 'sand';
if (score > .17) type = 'grass';
if (score > .56 && fractalPerlin(x * .16 + 8, y * .16 + 4, 2) > .56) type = 'highland';
const heightLevel = type === 'highland' ? 1 : 0;
tiles.push({ x, y, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 });
}
}
return {
tiles,
get(x, y) {
if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) return null;
return tiles[y * WORLD_W + x];
}
};
}
function buildTerrainCache(worldData) {
const chunkSize = TERRAIN_CHUNK_SIZE;
const chunks = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let cy = 0; cy < WORLD_H; cy += chunkSize) {
for (let cx = 0; cx < WORLD_W; cx += chunkSize) {
const tiles = [];
for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) {
for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) {
const tile = worldData.get(x, y);
if (tile) tiles.push(tile);
}
}
if (!tiles.length) continue;
const bounds = getTerrainChunkBounds(tiles);
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(bounds.w);
canvas.height = Math.ceil(bounds.h);
const c = canvas.getContext('2d', { alpha: true });
c.imageSmoothingEnabled = false;
c.save();
c.translate(-bounds.x, -bounds.y);
for (const tile of tiles) drawTerrainTile(c, tile, worldData);
c.restore();
chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy });
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + canvas.width);
maxY = Math.max(maxY, bounds.y + canvas.height);
}
}
const coastTiles = findCoastFoamTiles(worldData);
if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize, coastTiles };
return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize, coastTiles };
}
function findCoastFoamTiles(worldData) {
const out = [];
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
const tile = worldData.get(x, y);
if (!tile || tile.type !== 'water') continue;
let touchesLand = false;
for (const [dx, dy] of dirs) {
const near = worldData.get(x + dx, y + dy);
if (near && near.type !== 'water') { touchesLand = true; break; }
}
if (touchesLand) out.push({ x, y });
}
}
return out;
}
function getTerrainChunkBounds(tiles) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const tile of tiles) {
const p = tileToWorld(tile.x, tile.y);
const lift = getTileLift(tile);
minX = Math.min(minX, p.x - TILE_W / 2 - 3);
maxX = Math.max(maxX, p.x + TILE_W / 2 + 3);
minY = Math.min(minY, p.y - lift - 16);
maxY = Math.max(maxY, p.y + TILE_H + 18);
}
minX = Math.floor(minX);
minY = Math.floor(minY);
maxX = Math.ceil(maxX);
maxY = Math.ceil(maxY);
return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
}
function drawTerrainCache(cache) {
const rect = getViewportWorldRect(64);
for (const chunk of cache.chunks) {
if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue;
ctx.drawImage(chunk.canvas, chunk.x, chunk.y);
}
}
function buildCoastalFoamTextures() {
return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) };
}
function makeFoamTexture(size, seedOffset) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const c = canvas.getContext('2d', { alpha: true });
c.clearRect(0, 0, size, size);
for (let i = 0; i < 26; i++) {
const n = pseudoNoise(seedOffset * 93 + i * 17.3);
const x = Math.floor(n * (size - 6)) + 3;
const y = Math.floor(pseudoNoise(seedOffset * 77 + i * 11.1) * (size - 6)) + 3;
const r = 1 + Math.floor(pseudoNoise(seedOffset * 41 + i * 6.2) * 3);
c.fillStyle = i % 2 ? 'rgba(232, 248, 255, 0.42)' : 'rgba(216, 239, 248, 0.32)';
c.beginPath();
c.arc(x, y, r, 0, Math.PI * 2);
c.fill();
}
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);
if (tile.type === 'highland') {
const frontLeft = worldData.get(tile.x, tile.y + 1);
const frontRight = worldData.get(tile.x + 1, tile.y);
const leftLower = !frontLeft || frontLeft.type !== 'highland';
const rightLower = !frontRight || frontRight.type !== 'highland';
if (rightLower) {
c.fillStyle = '#7b8e6c';
c.beginPath();
c.moveTo(x, y + TILE_H - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
c.lineTo(x, y + TILE_H);
c.closePath();
c.fill();
}
if (leftLower) {
c.fillStyle = '#6f8263';
c.beginPath();
c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x, y + TILE_H);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
c.closePath();
c.fill();
}
}
c.fillStyle = fill;
c.beginPath();
c.moveTo(x, y - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.closePath();
c.fill();
if (tile.type === 'water') {
c.fillStyle = 'rgba(255,255,255,.045)';
c.beginPath();
c.moveTo(x - TILE_W * .20, y + TILE_H * .48);
c.lineTo(x + TILE_W * .15, y + TILE_H * .31);
c.lineTo(x + TILE_W * .25, y + TILE_H * .38);
c.lineTo(x - TILE_W * .10, y + TILE_H * .55);
c.closePath();
c.fill();
}
c.strokeStyle = 'rgba(36, 48, 68, .02)';
c.lineWidth = 1;
c.beginPath();
c.moveTo(x, y - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.closePath();
c.stroke();
}
function getTileLift(tile) {
return tile?.type === 'highland' ? 12 : 0;
}
function getLiftAtCoord(x, y) {
const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1));
return getTileLift(tile);
}
function getTerrainSurfaceColorAt(x, y) {
const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1));
const palette = {
water: '#8cd2ee',
sand: '#ead99d',
grass: '#91cf76',
highland: '#9db781'
};
return palette[tile?.type] || '#d9d2c0';
}
function 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 {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const expanded = Phase2Sync?.isCompactState?.(parsed) ? Phase2Sync.expandState(parsed) : parsed;
if (expanded && Array.isArray(expanded.assets)) return normalizeState(expanded);
}
} catch (error) {
console.warn('Could not load local state.', error);
}
return seedState();
}
function saveState() {
state.schema = SAVE_SCHEMA;
state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) };
state.moderationReports = normalizeModerationReports(state.moderationReports || []);
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [];
const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
cachePhase2State();
updateSyncStats();
updateGuardrailStats();
updateRotationStats();
}
function normalizeState(input) {
return {
schema: SAVE_SCHEMA,
authorName: input.authorName || 'Local Artist',
assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [],
placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []),
dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {},
moderationReports: normalizeModerationReports(input.moderationReports || []),
guardrails: { ...PHASE5_GUARDRAILS, ...(input.guardrails || {}) },
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [],
sync: input.sync || { lastEventId: null },
settings: { ...defaultVisualSettings(), ...(input.settings || {}) },
account: normalizeAccount(input.account),
publishLog: Array.isArray(input.publishLog) ? input.publishLog.slice(-300) : []
};
}
function normalizeAsset(asset) {
const size = Number(asset.size) || 16;
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size);
const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other');
const normalized = {
id: asset.id || uid(),
name: asset.name || 'Untitled',
category,
subtype,
size,
pixels: encodePixels(right),
faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null,
parentAssetId: asset.parentAssetId || null,
originalAssetId: asset.originalAssetId || null,
createdAt: asset.createdAt || Date.now(),
updatedAt: asset.updatedAt || asset.createdAt || Date.now(),
author: asset.author || 'Local Artist',
meta: normalizeAssetMeta(asset.meta || {}, category, subtype, size)
};
normalized.contentHash = computeAssetContentHash(normalized);
return normalized;
}
function normalizeAssetMeta(meta, category, subtype, size) {
const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size);
const lightPixels = Array.isArray(meta.lightPixels)
? meta.lightPixels
.map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
: [];
const legacyParticlePixels = Array.isArray(meta.particlePixels)
? meta.particlePixels
.map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 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, size, legacyParticlePixels);
}
function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize, sourceParticle = null) {
const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size);
const hasDepth = normalizedDepth.some(Boolean);
const lightPixelsClean = Array.isArray(sourceLightPixels)
? sourceLightPixels
.map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
: [];
const legacyParticlePixels = Array.isArray(sourceParticle)
? sourceParticle
.map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode('#ffffff'), dir: p.dir || 'up' }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
: [];
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' }, size);
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, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null
};
}
function normalizePlacements(items) {
return dedupeByAsset(items).map((item) => ({
id: item.id || uid(),
assetId: item.assetId,
x: clampInt(item.x, 0, WORLD_W - 1, 0),
y: clampInt(item.y, 0, WORLD_H - 1, 0),
placedAt: item.placedAt || item.createdAt || Date.now(),
publishedAt: item.publishedAt || item.placedAt || item.createdAt || Date.now(),
status: item.status || 'active',
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',
version: Number(item.version) || 1
})).filter((item) => item.assetId);
}
function dedupeByAsset(items) {
const map = new Map();
for (const item of items) {
if (item && item.assetId) map.set(item.assetId, item);
}
return [...map.values()];
}
function findNearestTerrain(type, startX, startY) {
let best = null;
let bestD = Infinity;
const maxR = Math.max(WORLD_W, WORLD_H);
for (let r = 0; r < maxR; r++) {
for (let y = Math.max(0, startY - r); y <= Math.min(WORLD_H - 1, startY + r); y++) {
for (let x = Math.max(0, startX - r); x <= Math.min(WORLD_W - 1, startX + r); x++) {
const tile = world.get(x, y);
if (!tile || tile.type !== type) continue;
const d = Math.hypot(x - startX, y - startY);
if (d < bestD) { bestD = d; best = { x, y }; }
}
}
if (best) return best;
}
return { x: startX, y: startY };
}
function homeFromPos(pos) {
return { homeX: pos.x, homeY: pos.y };
}
function seedState() {
const assets = [
makeAsset('Hill Cottage', 'static', 'building', drawCottage(), { hasLight: true, lightPixels: [{ x: 8, y: 8 }, { x: 9, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }),
makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster(), { particlePixels: [{ x: 7, y: 4, c: nearestPaletteCode('#9bdc6d') }, { x: 10, y: 6, c: nearestPaletteCode('#f4a1bb') }] }),
makeAsset('Shell Rock', 'static', 'nature', drawShellRock(), { particlePixels: [{ x: 8, y: 9, c: nearestPaletteCode('#ffffff') }] }),
makeAsset('Wave Skiff', 'static', 'ship', drawShip(), { depthPixels: drawShipDepth(), hasLight: false }),
makeAsset('Fisher Kid', 'dynamic', 'human', drawFisherKidRight()),
makeAsset('Moss Cat', 'dynamic', 'animal', drawCatRight()),
makeAsset('Koi Fish', 'dynamic', 'fish', drawKoiRight()),
makeAsset('Cloud Gull', 'dynamic', 'bird', drawGullRight())
];
const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id]));
const shipPos = findNearestTerrain('water', 62, 58);
const fishPos = findNearestTerrain('water', 66, 60);
return {
schema: SAVE_SCHEMA,
authorName: 'Local Artist',
assets,
placed: [
{ id: uid(), assetId: idByName['Hill Cottage'], x: 36, y: 36, placedAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Pine Cluster'], x: 32, y: 36, placedAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Shell Rock'], x: 39, y: 40, placedAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Wave Skiff'], ...shipPos, placedAt: Date.now(), version: 1 }
],
objectVotes: {},
assetVotes: {},
hiddenAssets: {},
hiddenObjects: {},
moderationReports: [],
guardrails: { ...PHASE5_GUARDRAILS },
settings: defaultVisualSettings(),
account: null,
publishLog: [],
dynamicSummons: [
{ id: uid(), assetId: idByName['Fisher Kid'], homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Moss Cat'], homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Koi Fish'], ...homeFromPos(fishPos), createdAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Cloud Gull'], homeX: 90, homeY: 30, createdAt: Date.now(), version: 1 }
]
};
}
function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) {
const size = 16;
const id = uid();
const right = alignPixelsToBottom(normalizePixels(pixels, size), size);
const asset = {
id,
name,
category,
subtype,
size,
pixels: encodePixels(right),
faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null,
parentAssetId: null,
originalAssetId: null,
createdAt: Date.now(),
updatedAt: Date.now(),
author: 'Island Team',
meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size, meta.particleConfig || meta.particlePixels || [])
};
asset.contentHash = computeAssetContentHash(asset);
return asset;
}
function alignPixelsToBottom(pixels, size) {
const source = normalizePixels(pixels, size);
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (source[y * size + x]) maxY = Math.max(maxY, y);
}
}
if (maxY < 0 || maxY === size - 1) return source;
const dy = size - 1 - maxY;
const out = blankPixels(size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const value = source[y * size + x];
if (value && y + dy < size) out[(y + dy) * size + x] = value;
}
}
return out;
}
function exportData() {
els.dataBox.value = JSON.stringify(state, null, 2);
toast('Full JSON exported.');
}
function exportCompactData() {
const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
els.dataBox.value = JSON.stringify(payload);
toast('Compact JSON exported.');
}
function exportSnapshotData() {
if (!Phase2Sync?.makeSnapshot) {
exportData();
return;
}
els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state));
toast('Snapshot JSON exported.');
}
function exportAssetBundleData() {
if (!Phase2Sync?.makeAssetBundle) {
exportData();
return;
}
els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id)));
toast('Asset bundle JSON exported.');
}
function importData() {
try {
const imported = JSON.parse(els.dataBox.value);
if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) {
toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.');
return;
}
if (Phase2Sync?.isAssetBundle?.(imported)) {
const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset);
const byId = new Map(state.assets.map((asset) => [asset.id, asset]));
importedAssets.forEach((asset) => byId.set(asset.id, asset));
state.assets = [...byId.values()];
saveState();
spriteCache.clear();
renderLibrary();
updateSelectedLabel();
toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`);
return;
}
const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported;
state = normalizeState(expanded);
rebuildWorldIndex();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('JSON imported.');
} catch (error) {
toast('Invalid JSON.');
console.error(error);
}
}
function resetAll() {
if (!confirm('Reset all local assets and island objects?')) return;
localStorage.removeItem(STORAGE_KEY);
state = seedState();
rebuildWorldIndex();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('Local world reset.');
}
function recordSyncEvent(event) {
if (!event) return;
state.eventLog ||= [];
state.eventLog.push(event);
state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300));
state.sync ||= { lastEventId: null };
state.sync.lastEventId = event.id;
}
function cachePhase2State() {
if (!Phase2Sync?.cacheAssets) return;
Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error));
if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) {
Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error));
}
}
function getWorldObjectCount() {
return (state.placed?.length || 0) + (state.dynamicSummons?.length || 0);
}
function normalizeModerationReports(input) {
const list = Array.isArray(input) ? input : [];
return list
.map((report) => ({
id: report.id || uid(),
objectId: report.objectId || report.id || '',
assetId: report.assetId || '',
objectKind: report.objectKind === 'dynamic' ? 'dynamic' : 'static',
assetName: report.assetName || 'Untitled',
assetAuthor: report.assetAuthor || 'Local Artist',
reporter: report.reporter || 'local',
reason: report.reason || 'local-report',
createdAt: Number(report.createdAt) || Date.now()
}))
.filter((report) => report.objectId && report.assetId)
.slice(-PHASE5_GUARDRAILS.maxReports);
}
function collectWorldValidation() {
const assetIds = new Set((state.assets || []).map((asset) => asset.id));
const contentCounts = new Map();
for (const asset of state.assets || []) {
const key = asset.contentHash || computeAssetContentHash(asset);
contentCounts.set(key, (contentCounts.get(key) || 0) + 1);
}
const orphanStatic = (state.placed || []).filter((item) => !assetIds.has(item.assetId));
const orphanDynamic = (state.dynamicSummons || []).filter((item) => !assetIds.has(item.assetId));
const invalidTerrain = [];
for (const item of state.placed || []) {
const asset = findAsset(item.assetId);
const tile = world.get(item.x, item.y);
if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'static', id: item.id, assetId: item.assetId, x: item.x, y: item.y });
}
for (const item of state.dynamicSummons || []) {
const asset = findAsset(item.assetId);
const tile = world.get(Math.round(item.homeX), Math.round(item.homeY));
if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'dynamic', id: item.id, assetId: item.assetId, x: item.homeX, y: item.homeY });
}
const duplicateContent = [...contentCounts.values()].filter((count) => count > 1).reduce((sum, count) => sum + count - 1, 0);
return {
schema: SAVE_SCHEMA,
generatedAt: new Date().toISOString(),
assets: state.assets?.length || 0,
worldObjects: getWorldObjectCount(),
hiddenObjects: Object.keys(state.hiddenObjects || {}).length,
reports: state.moderationReports?.length || 0,
duplicateContent,
orphanStatic: orphanStatic.length,
orphanDynamic: orphanDynamic.length,
invalidTerrain: invalidTerrain.length,
invalidTerrainObjects: invalidTerrain,
limits: state.guardrails || PHASE5_GUARDRAILS
};
}
function isTerrainCompatible(asset, tile) {
if (!asset || !tile) return false;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water';
if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water';
return tile.type !== 'water';
}
function updateGuardrailStats() {
if (!els.guardrailStats) return;
const report = collectWorldValidation();
const assetText = `${report.assets}/${report.limits.maxAssets}`;
const objectText = `${report.worldObjects}/${report.limits.maxWorldObjects}`;
const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain;
els.guardrailStats.textContent = `Assets ${assetText}, objects ${objectText}, reports ${report.reports}, hidden ${report.hiddenObjects}, duplicate-content copies ${report.duplicateContent}, validation warnings ${warnings}.`;
}
function validateWorld() {
const report = collectWorldValidation();
els.dataBox.value = JSON.stringify({ format: 'pixel-island-phase5-validation-v1', ...report }, null, 2);
updateGuardrailStats();
const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain;
toast(warnings ? `Validation found ${warnings} warning(s).` : 'Validation passed.');
}
function exportModerationReport() {
const validation = collectWorldValidation();
const payload = {
format: 'pixel-island-phase5-moderation-v1',
generatedAt: validation.generatedAt,
authorName: state.authorName || 'Local Artist',
reports: normalizeModerationReports(state.moderationReports || []),
hiddenObjects: state.hiddenObjects || {},
downvotedObjects: Object.entries(state.objectVotes || {})
.map(([objectId, votes]) => ({ objectId, up: votes.up || 0, down: votes.down || 0, score: (votes.up || 0) - (votes.down || 0) }))
.filter((item) => item.down > 0),
validation
};
els.dataBox.value = JSON.stringify(payload, null, 2);
toast('Moderation report exported.');
}
function clearModerationReports() {
if (!state.moderationReports?.length) {
toast('No local reports to clear.');
return;
}
if (!confirm('Clear local moderation reports? Hidden objects stay hidden.')) return;
state.moderationReports = [];
saveState();
updateGuardrailStats();
toast('Local reports cleared.');
}
function updateSyncStats() {
if (!els.syncStats) return;
const report = Phase2Sync?.compactSizeReport?.(state);
if (!report) {
els.syncStats.textContent = 'Phase 2 codec unavailable.';
return;
}
const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet';
const statsRect = getViewportWorldRect(64);
const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0;
els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`;
}
function computeAssetContentHash(asset) {
const payload = [
asset.category || '',
asset.subtype || '',
asset.size || '',
asset.pixels || '',
asset.faces?.right || '',
asset.faces?.left || '',
asset.meta?.depthPixels || '',
JSON.stringify(asset.meta?.lightPixels || []),
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 fnv1a(value) {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function clampInt(value, min, max, fallback = min) {
const n = Math.round(Number(value));
return Number.isFinite(n) ? clamp(n, min, max) : fallback;
}
function findAsset(id) {
return state.assets.find((asset) => asset.id === id) || null;
}
function blankPixels(size) {
return Array(size * size).fill(null);
}
function normalizePixels(pixels, size) {
const out = blankPixels(size);
if (typeof pixels === 'string') {
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
const ch = pixels[i];
out[i] = ch === '.' ? null : (PALETTE_BY_CODE[ch] ? ch : nearestPaletteCode(ch));
}
return out;
}
if (!Array.isArray(pixels)) return out;
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
const value = pixels[i];
if (!value) out[i] = null;
else if (PALETTE_BY_CODE[value]) out[i] = value;
else out[i] = nearestPaletteCode(value);
}
return out;
}
function encodePixels(pixels) {
return normalizePixels(pixels, Math.sqrt(pixels.length) || editorSize).map((value) => value || '.').join('');
}
function colorToHex(value) {
if (!value) return 'rgba(0,0,0,0)';
return PALETTE_BY_CODE[value] || value;
}
function nearestPaletteCode(color) {
if (!color || typeof color !== 'string') return null;
if (PALETTE_BY_CODE[color]) return color;
const rgb = parseHex(color);
if (!rgb) return selectedColorCode;
let best = PALETTE[0].code;
let bestDist = Infinity;
for (const entry of PALETTE) {
const p = parseHex(entry.color);
const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2;
if (dist < bestDist) { bestDist = dist; best = entry.code; }
}
return best;
}
function readableTextColor(hex) {
const rgb = parseHex(hex);
if (!rgb) return '#243044';
const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
return yiq > 140 ? '#243044' : '#fffdf5';
}
function parseHex(hex) {
if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
const clean = hex.replace('#', '');
const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
const value = parseInt(full, 16);
if (!Number.isFinite(value)) return null;
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
}
function normalizeDepthPixels(input, size) {
const out = Array(size * size).fill(0);
if (typeof input === 'string') {
for (let i = 0; i < Math.min(out.length, input.length); i++) {
const ch = input[i];
out[i] = ch === '1' ? 1 : (ch === '-' || ch === '_' || ch === 'l' || ch === 'L') ? -1 : 0;
}
return out;
}
if (!Array.isArray(input)) return out;
for (let i = 0; i < Math.min(out.length, input.length); i++) {
const value = Number(input[i]) || 0;
out[i] = value > 0 ? 1 : value < 0 ? -1 : 0;
}
return out;
}
function encodeDepthPixels(input) {
return Array.from(input || []).map((v) => Number(v) > 0 ? '1' : Number(v) < 0 ? '-' : '.').join('');
}
function resizeDepthPixels(source, oldSize, newSize) {
const normalized = normalizeDepthPixels(source, oldSize);
const out = Array(newSize * newSize).fill(0);
const min = Math.min(oldSize, newSize);
const xOffset = Math.floor((newSize - min) / 2);
const yOffset = Math.floor((newSize - min) / 2);
const oldOffset = Math.floor((oldSize - min) / 2);
for (let y = 0; y < min; y++) {
for (let x = 0; x < min; x++) {
out[(y + yOffset) * newSize + (x + xOffset)] = normalized[(y + oldOffset) * oldSize + (x + oldOffset)] || 0;
}
}
return out;
}
function getAssetDepth(asset, x, y, side = 'right') {
const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
const sx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - x : x;
return depth[y * asset.size + sx] || 0;
}
function applyDepthToColor(hex, depth) {
return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase);
}
function shadeAssetPixelColor(hex, depth, x, y, size, phase, pixels = null, depths = null, lights = []) {
const rgb = parseHex(hex);
if (!rgb) return hex;
const activePhase = phase || renderPhase || getPhase();
const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1);
const vertical = 1 - (y / Math.max(1, size - 1));
const horizontal = ((x / Math.max(1, size - 1)) - 0.5) * dirX;
const sunlight = (activePhase?.key === 'night' ? 6 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 9 : 11);
const depthBoost = depth * sunlight * 0.35;
const exposure = (vertical * 0.62 + horizontal * 0.5) * (activePhase?.key === 'night' ? 6 : 10);
const edgeBoost = getDepthEdgeLightBoost(depth, x, y, size, pixels, depths, lights, activePhase);
let r = rgb.r + depthBoost + exposure + edgeBoost;
let g = rgb.g + depthBoost + exposure + edgeBoost;
let b = rgb.b + depthBoost + exposure + edgeBoost;
if (areNightLightsActive(activePhase) && lights?.length) {
for (const light of lights) {
const lc = parseHex(light.c || '#ffd86a');
if (!lc) continue;
const dist = Math.hypot((light.x + 0.5) - (x + 0.5), (light.y + 0.5) - (y + 0.5));
const reach = Math.max(2.25, size * 0.42);
const t = 1 - clamp(dist / reach, 0, 1);
if (t <= 0) continue;
const edge = getDepthLightFacing(depth, x, y, size, pixels, depths, light.x, light.y);
const depthFactor = depth > 0 ? 1.08 + edge * 0.30 : depth < 0 ? 0.72 + edge * 0.16 : 0.54 + edge * 0.08;
const strength = t * t * (10 + Math.abs(depth) * 7) * depthFactor;
const mix = Math.min(0.52, 0.12 + t * (depth ? 0.24 : 0.18));
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.88);
}
}
r = clamp(r, 0, 255);
g = clamp(g, 0, 255);
b = clamp(b, 0, 255);
return `#${Math.round(r).toString(16).padStart(2,'0')}${Math.round(g).toString(16).padStart(2,'0')}${Math.round(b).toString(16).padStart(2,'0')}`;
}
function mirrorDepthPixels(depths, size) {
const source = normalizeDepthPixels(depths || [], size);
const out = Array(size * size).fill(0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) out[y * size + (size - 1 - x)] = source[y * size + x] || 0;
}
return out;
}
function getAssetLightPointsForSide(asset, side = 'right') {
const size = asset.size || 1;
const lights = Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels : [];
return lights.map((p) => ({
x: asset.category === 'dynamic' && side === 'left' ? size - 1 - p.x : p.x,
y: p.y,
c: colorToHex(p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a'))
})).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
}
function isObjectOutlinePixel(pixels, x, y, size) {
if (!pixels || !pixels[y * size + 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 >= size || ny >= size) return true;
if (!pixels[ny * size + nx]) return true;
}
return false;
}
function collectDepthEdgeDirs(x, y, size, pixels, depths, depth) {
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 < size && ny < size;
const neighborOpaque = neighborInside && pixels[ny * size + nx];
const neighborDepth = neighborOpaque ? (depths[ny * size + nx] || 0) : 0;
if (!neighborOpaque || neighborDepth !== depth) edgeDirs.push([dx, dy]);
}
return edgeDirs;
}
function getDepthLightFacing(depth, x, y, size, pixels, depths, lightX, lightY) {
if (!depth || !pixels || !depths) return 0;
const edgeDirs = collectDepthEdgeDirs(x, y, size, pixels, depths, depth);
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, size, pixels, depths, lights, phase) {
if (!depth || !pixels || !depths || !areNightLightsActive(phase) || !lights?.length) return 0;
let best = null, bestD = Infinity;
for (const light of lights) {
const d = Math.hypot(light.x - x, light.y - y);
if (d < bestD) { bestD = d; best = light; }
}
if (!best) return 0;
const facing = getDepthLightFacing(depth, x, y, size, pixels, depths, best.x, best.y);
if (!facing) return 0;
const outerEdge = collectDepthEdgeDirs(x, y, size, pixels, depths, depth).some(([dx, dy]) => {
const nx = x + dx, ny = y + dy;
return nx < 0 || ny < 0 || nx >= size || ny >= size || !pixels[ny * size + nx];
});
const distanceFalloff = 1 - clamp(bestD / Math.max(2.25, size * 0.44), 0, 1);
if (distanceFalloff <= 0) return 0;
const strength = outerEdge ? 0.64 : 1;
return facing * strength * distanceFalloff * distanceFalloff * (depth > 0 ? 20 : 14);
}
function resizePixels(source, oldSize, newSize) {
const out = blankPixels(newSize);
const min = Math.min(oldSize, newSize);
const xOffset = Math.floor((newSize - min) / 2);
const yOffset = Math.floor((newSize - min) / 2);
const oldOffset = Math.floor((oldSize - min) / 2);
for (let y = 0; y < min; y++) {
for (let x = 0; x < min; x++) {
out[(y + yOffset) * newSize + (x + xOffset)] = source[(y + oldOffset) * oldSize + (x + oldOffset)] || null;
}
}
return out;
}
function mirrorPixels(pixels, size) {
const out = blankPixels(size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
out[y * size + (size - 1 - x)] = pixels[y * size + x] || null;
}
}
return out;
}
function hasAnyPixel(pixels) {
return Array.isArray(pixels) && pixels.some(Boolean);
}
function fade(t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function hash2(ix, iy) {
let h = ix * 374761393 + iy * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return (h ^ (h >> 16)) >>> 0;
}
function gradDot(ix, iy, x, y) {
const h = hash2(ix, iy) & 7;
const gx = [1, -1, 1, -1, 1, -1, 0, 0][h];
const gy = [1, 1, -1, -1, 0, 0, 1, -1][h];
return gx * (x - ix) + gy * (y - iy);
}
function perlinNoise(x, y) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const x1 = x0 + 1, y1 = y0 + 1;
const sx = fade(x - x0), sy = fade(y - y0);
const n00 = gradDot(x0, y0, x, y);
const n10 = gradDot(x1, y0, x, y);
const n01 = gradDot(x0, y1, x, y);
const n11 = gradDot(x1, y1, x, y);
const ix0 = lerp(n00, n10, sx);
const ix1 = lerp(n01, n11, sx);
return lerp(ix0, ix1, sy) * .5 + .5;
}
function fractalPerlin(x, y, octaves = 4) {
let amp = .5;
let freq = 1;
let total = 0;
let norm = 0;
for (let i = 0; i < octaves; i++) {
total += perlinNoise(x * freq, y * freq) * amp;
norm += amp;
amp *= .5;
freq *= 2;
}
return total / norm;
}
function smoothNoise(x, y) {
return Math.sin(x * .71 + y * .37) * .5 + Math.sin(x * .23 - y * .61) * .32 + Math.sin((x + y) * .17) * .18;
}
function cap(value) {
return `${value}`.charAt(0).toUpperCase() + `${value}`.slice(1);
}
function buildPalette() {
const neutrals = ['#fffdf7','#f3ead8','#dfd2bc','#c2b39d','#9b9083','#776d67','#5a5454','#403d42','#2d2d35','#14151c'];
const hues = [0, 24, 48, 72, 96, 132, 168, 204, 228, 264, 288, 324, 348];
const lights = [72, 58, 46, 34];
const colors = [...neutrals];
for (const light of lights) {
for (const hue of hues) colors.push(hslToHex(hue, 72, light));
}
return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] }));
}
function pseudoNoise(value) {
const x = Math.sin(value * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0');
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}
function hexToRgba(hex, alpha) {
const clean = hex.replace('#', '');
const bigint = parseInt(clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function hexToRgbParts(hex) {
const clean = String(hex || '#000000').replace('#', '');
const expanded = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean.padEnd(6, '0').slice(0, 6);
const bigint = parseInt(expanded, 16);
return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 };
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s; const l = (max + min) / 2;
if (max === min) { h = 0; s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
default: h = (r - g) / d + 4; break;
}
h *= 60;
}
return { h, s: s * 100, l: l * 100 };
}
function varyHexColor(hex, hueRange = 12, satRange = 10, lightRange = 10) {
const rgb = parseHex(hex);
if (!rgb) return hex;
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
const h = mod(hsl.h + (Math.random() * 2 - 1) * hueRange, 360);
const s = clamp(hsl.s + (Math.random() * 2 - 1) * satRange, 14, 100);
const l = clamp(hsl.l + (Math.random() * 2 - 1) * lightRange, 10, 94);
return hslToHex(h, s, l);
}
function mixHex(a, b, t) {
const ca = hexToRgbParts(a);
const cb = hexToRgbParts(b);
const toHex = (value) => Math.round(value).toString(16).padStart(2, '0');
return `#${toHex(lerp(ca.r, cb.r, t))}${toHex(lerp(ca.g, cb.g, t))}${toHex(lerp(ca.b, cb.b, t))}`;
}
function toast(message) {
clearTimeout(toastTimer);
els.toast.textContent = message;
els.toast.hidden = false;
toastTimer = setTimeout(() => { els.toast.hidden = true; }, 1900);
}
function px(pixels, size, x, y, color) {
if (x >= 0 && x < size && y >= 0 && y < size) pixels[y * size + x] = color;
}
function rect(pixels, size, x, y, w, h, color) {
for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) px(pixels, size, xx, yy, color);
}
function circle(pixels, size, cx, cy, r, color) {
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) {
if ((x - cx) ** 2 + (y - cy) ** 2 <= r ** 2) px(pixels, size, x, y, color);
}
}
}
function drawCottage() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 8, 8, 6, '#b86b4f');
rect(p, s, 5, 9, 6, 4, '#d68b5e');
rect(p, s, 6, 11, 3, 3, '#3b3348');
rect(p, s, 10, 9, 2, 2, '#ffe38a');
rect(p, s, 3, 7, 10, 1, '#6a485d');
for (let y = 3; y <= 7; y++) {
const inset = Math.abs(6 - y);
for (let x = 3 + inset; x <= 12 - inset; x++) px(p, s, x, y, y < 5 ? '#8e4f61' : '#74465b');
}
px(p, s, 6, 8, '#ffcf75'); px(p, s, 11, 12, '#7b493f');
return p;
}
function drawPineCluster() {
const s = 16, p = blankPixels(s);
rect(p, s, 6, 9, 2, 5, '#765238');
rect(p, s, 10, 10, 2, 4, '#765238');
for (let y = 2; y <= 10; y++) {
const w = Math.floor((y + 1) / 2);
for (let x = 7 - w; x <= 7 + w; x++) px(p, s, x, y, y % 2 ? '#348f5a' : '#42aa68');
}
for (let y = 5; y <= 11; y++) {
const w = Math.floor((y - 2) / 2);
for (let x = 11 - w; x <= 11 + w; x++) px(p, s, x, y, y % 2 ? '#2f7f55' : '#3d9d62');
}
px(p, s, 5, 4, '#8ee68c'); px(p, s, 10, 6, '#8ee68c');
return p;
}
function drawShellRock() {
const s = 16, p = blankPixels(s);
rect(p, s, 5, 10, 7, 3, '#b9ad9f');
rect(p, s, 4, 11, 9, 2, '#968b83');
px(p, s, 6, 9, '#f0e0ca'); px(p, s, 8, 9, '#f0e0ca'); px(p, s, 10, 9, '#f0e0ca');
rect(p, s, 7, 8, 4, 2, '#ffb6b1');
px(p, s, 9, 7, '#ffd7cb'); px(p, s, 11, 10, '#6d625f');
return p;
}
function drawShip() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 10, 8, 2, '#7d513c');
rect(p, s, 5, 12, 6, 1, '#50392f');
px(p, s, 3, 9, '#7d513c'); px(p, s, 12, 9, '#7d513c');
rect(p, s, 8, 4, 1, 6, '#5b4b48');
for (let y = 4; y <= 8; y++) for (let x = 9; x <= 12 - Math.floor((y - 4) / 2); x++) px(p, s, x, y, '#f3ead8');
for (let y = 5; y <= 8; y++) for (let x = 5 + Math.floor((y - 5) / 2); x <= 7; x++) px(p, s, x, y, '#ffd66e');
px(p, s, 10, 5, '#ffffff'); px(p, s, 6, 6, '#fff7d1');
return p;
}
function drawShipDepth() {
const s = 16, d = Array(s * s).fill(0);
for (let y = 10; y <= 12; y++) for (let x = 4; x <= 12; x++) d[y * s + x] = -1;
for (let y = 4; y <= 8; y++) d[y * s + 8] = 1;
return d;
}
function drawFisherKidRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 3, 3, 3, '#d9a06c');
rect(p, s, 6, 6, 5, 4, '#e2b94f');
rect(p, s, 6, 10, 2, 4, '#31577e');
rect(p, s, 9, 10, 2, 4, '#31577e');
rect(p, s, 6, 2, 5, 1, '#35434a');
px(p, s, 10, 4, '#202631');
px(p, s, 12, 7, '#5b4b48'); px(p, s, 13, 8, '#5b4b48');
return p;
}
function drawCatRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 8, 7, 4, '#5c8a57');
rect(p, s, 10, 7, 3, 3, '#6fa064');
px(p, s, 10, 6, '#6fa064'); px(p, s, 12, 6, '#6fa064');
px(p, s, 12, 8, '#1e2430'); px(p, s, 13, 9, '#d9a06c');
rect(p, s, 5, 12, 1, 2, '#3e633e'); rect(p, s, 9, 12, 1, 2, '#3e633e');
px(p, s, 3, 8, '#5c8a57'); px(p, s, 2, 7, '#5c8a57');
return p;
}
function drawKoiRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 5, 7, 6, 3, '#f7f2e8');
px(p, s, 6, 7, '#ff7d5a'); px(p, s, 8, 8, '#ff7d5a'); px(p, s, 10, 9, '#ff7d5a');
px(p, s, 11, 8, '#1d2530');
px(p, s, 4, 7, '#ffb165'); px(p, s, 3, 6, '#ffb165'); px(p, s, 3, 10, '#ffb165');
px(p, s, 7, 6, '#fffdf7'); px(p, s, 9, 10, '#fffdf7');
return p;
}
function drawGullRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 6, 3, 3, '#fffdf7');
rect(p, s, 4, 6, 3, 1, '#dfe6ee');
rect(p, s, 10, 5, 4, 1, '#dfe6ee');
px(p, s, 10, 7, '#f0a45c');
px(p, s, 8, 5, '#1e2430');
px(p, s, 6, 8, '#c9d2dc'); px(p, s, 9, 8, '#c9d2dc');
return p;
}
bootstrap();
})();