(() => {
'use strict';
console.info('Pixel Island Summoner loaded');
const STORAGE_KEY = 'pixel-island-summoner:phase5c';
const SAVE_SCHEMA = 9;
const WORLD_W = 144;
const WORLD_H = 112;
const TILE_W = 32;
const TILE_H = 16;
const ORIGIN_X = WORLD_H * TILE_W / 2 + 80;
const ORIGIN_Y = 42;
const DAY_MS = 10 * 60 * 1000;
const STATIC_SCALE = 2;
const DYNAMIC_SCALE = 1;
const MAX_ZOOM = 3.2;
const MIN_ZOOM = 0.45;
const TERRAIN_CHUNK_SIZE = 16;
const VIEW_CULL_MARGIN = 192;
const DYNAMIC_LOGIC_STEP_MS = 1000 / 15;
const MAX_DYNAMIC_STEPS_PER_FRAME = 3;
const COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
const MODULES = window.PixelIslandModules || {};
const EditorActions = MODULES.EditorActions || null;
const Phase2Sync = MODULES.Phase2Sync || null;
const EDITOR_HISTORY_LIMIT = 80;
const PHASE5_GUARDRAILS = { maxAssets: 160, maxWorldObjects: 220, maxReports: 200 };
const $ = (id) => document.getElementById(id);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-5);
const mod = (n, m) => ((n % m) + m) % m;
const lerp = (a, b, t) => a + (b - a) * t;
function defaultVisualSettings() {
return { enableLights: true, enableParticles: true, enableDayNight: true };
}
function visualSettings() {
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
return state.settings;
}
function hydrateVisualSettingsUI() {
const settings = visualSettings();
if (els.settingLights) els.settingLights.checked = settings.enableLights !== false;
if (els.settingParticles) els.settingParticles.checked = settings.enableParticles !== false;
if (els.settingDayNight) els.settingDayNight.checked = settings.enableDayNight !== false;
}
function clearVisualEffectState() {
spawnEffects = [];
bubbleParticles = [];
confettiParticles = [];
landStepParticles = [];
natureDriftParticles = [];
}
function toggleHidden(el, hidden) {
if (el) el.hidden = hidden;
}
function currentRole() {
return els.assetCategory?.value || 'nature';
}
function roleToCategory(role) {
return role === 'human' || role === 'animal' ? 'dynamic' : 'static';
}
function roleToSubtype(role) {
return role || 'other';
}
function subtypeToRole(asset) {
const subtype = asset?.subtype;
if (['human', 'animal', 'nature', 'building', 'ship', 'other'].includes(subtype)) return subtype;
if (subtype === 'water') return 'other';
if (asset?.category === 'dynamic') return 'animal';
return 'other';
}
const els = {
canvas: $('worldCanvas'),
openEditor: $('openEditor'),
closeEditor: $('closeEditor'),
drawer: $('studioDrawer'),
tabs: [...document.querySelectorAll('.tab')],
panels: [...document.querySelectorAll('.tabPanel')],
phaseLabel: $('phaseLabel'),
phaseBar: $('phaseBar'),
authorName: $('authorName'),
selectedAssetName: $('selectedAssetName'),
tileInfo: $('tileInfo'),
toast: $('toast'),
selectionBubble: $('selectionBubble'), bubbleName: $('bubbleName'), bubbleAuthor: $('bubbleAuthor'), bubbleRemixFrom: $('bubbleRemixFrom'), bubbleRemixCount: $('bubbleRemixCount'), voteScore: $('voteScore'), voteUp: $('voteUp'), voteDown: $('voteDown'), bubbleRemix: $('bubbleRemix'), bubbleReport: $('bubbleReport'), bubbleHide: $('bubbleHide'),
modeInspect: $('modeInspect'), modePlace: $('modePlace'), modeErase: $('modeErase'),
drawerInspect: $('drawerInspect'), drawerPlace: $('drawerPlace'), drawerErase: $('drawerErase'),
zoomOut: $('zoomOut'), zoomIn: $('zoomIn'), resetView: $('resetView'),
drawerZoomOut: $('drawerZoomOut'), drawerZoomIn: $('drawerZoomIn'), drawerResetView: $('drawerResetView'),
assetName: $('assetName'),
assetSize: $('assetSize'),
assetCategory: $('assetCategory'),
staticKindWrap: $('staticKindWrap'),
dynamicKindWrap: $('dynamicKindWrap'),
roleHint: $('roleHint'),
sideSwitcher: $('sideSwitcher'),
editRight: $('editRight'), editLeft: $('editLeft'),
paintColor: $('paintColor'), toolBrush: $('toolBrush'), toolErase: $('toolErase'), toolFill: $('toolFill'), toolPick: $('toolPick'), toolLine: $('toolLine'), toolRect: $('toolRect'), toolSelect: $('toolSelect'), undoPaint: $('undoPaint'), redoPaint: $('redoPaint'),
toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), depthHigh: $('depthHigh'), depthLow: $('depthLow'), depthClear: $('depthClear'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'), flipHorizontal: $('flipHorizontal'), flipVertical: $('flipVertical'), outlinePaint: $('outlinePaint'), clearSelection: $('clearSelection'), nudgeLeft: $('nudgeLeft'), nudgeRight: $('nudgeRight'), nudgeUp: $('nudgeUp'), nudgeDown: $('nudgeDown'), exportPng: $('exportPng'), importPng: $('importPng'), pngImportInput: $('pngImportInput'),
paintCanvas: $('paintCanvas'), editHint: $('editHint'), paletteGrid: $('paletteGrid'),
lightColor: $('lightColor'), staticSettingsPanel: $('staticSettingsPanel'), dynamicSettingsPanel: $('dynamicSettingsPanel'), doorMarkerHint: $('doorMarkerHint'),
settingsSummary: $('settingsSummary'), saveAsset: $('saveAsset'), saveAndPlace: $('saveAndPlace'), newAsset: $('newAsset'),
lineageNote: $('lineageNote'), assetList: $('assetList'), showHiddenAssets: $('showHiddenAssets'), hiddenAssetPanel: $('hiddenAssetPanel'), hiddenAssetList: $('hiddenAssetList'),
exportData: $('exportData'), importData: $('importData'), resetAll: $('resetAll'), dataBox: $('dataBox'),
exportCompact: $('exportCompact'), exportSnapshot: $('exportSnapshot'), exportAssetBundle: $('exportAssetBundle'), syncStats: $('syncStats'), guardrailStats: $('guardrailStats'), validateWorld: $('validateWorld'), exportModerationReport: $('exportModerationReport'), clearReports: $('clearReports'),
settingLights: $('settingLights'), settingParticles: $('settingParticles'), settingDayNight: $('settingDayNight'),
reportDialog: $('reportDialog'), reportReason: $('reportReason'), reportCancel: $('reportCancel'), reportSubmit: $('reportSubmit'), reportObjectName: $('reportObjectName')
};
const ctx = els.canvas.getContext('2d', { alpha: false });
const pctx = els.paintCanvas.getContext('2d', { alpha: true });
ctx.imageSmoothingEnabled = false;
pctx.imageSmoothingEnabled = false;
let dpr = window.devicePixelRatio || 1;
let cw = 1;
let ch = 1;
let world = makeWorld();
let terrainCache = buildTerrainCache(world);
let state = loadState();
let selectedAssetId = state.assets[0]?.id ?? null;
let mode = 'inspect';
let view = { x: 0, y: 0, zoom: 1 };
let pointer = {
down: false, id: null, startX: 0, startY: 0, lastX: 0, lastY: 0,
dragging: false, downTime: 0, button: 0
};
let hoverTile = null;
let dynamicRuntime = [];
let spriteCache = new Map();
let lastRuntimeUpdate = performance.now();
let lastClockSecond = -1;
let toastTimer = null;
let editorSize = 8;
let editorPixels = blankPixels(8);
let editorLeftPixels = blankPixels(8);
let editingSide = 'right';
let paintTool = 'brush';
let selectedColorCode = 'a';
let advancedDraw = false;
let depthPixels = blankPixels(8).map(() => 0);
let depthPaintMode = 1;
let isPainting = false;
let staticKind = 'nature';
let dynamicKind = 'human';
let lightPixels = [];
let doorPixel = { x: 8, y: 15 };
let editParentId = null;
let editOriginalId = null;
let editingAssetId = null;
let lastPaintedKey = '';
let spawnEffects = [];
let bubbleParticles = [];
let confettiParticles = [];
let landStepParticles = [];
let natureDriftParticles = [];
let coastalFoamTextures = null;
let mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 };
let shadowCanvasCache = new WeakMap();
let animationFrameId = 0;
let dynamicLogicRemainder = 0;
let worldIndex = makeEmptyWorldIndex();
let selectedObject = null;
let pendingReport = null;
let libraryFilter = 'all';
let renderPhase = null;
let editorView = { zoom: 1, x: 0, y: 0 };
let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 };
let editorHistory = [];
let editorFuture = [];
let editorGestureSnapshot = null;
let editorGestureChanged = false;
let suppressNextPaintClick = false;
let suppressMousePaintUntil = 0;
let editorSelection = null;
let selectionGesture = null;
let shapeGesture = null;
let shapePreview = null;
function bootstrap() {
resizeCanvas();
resetView(false);
hydrateRuntime();
coastalFoamTextures = buildCoastalFoamTextures();
wireUI();
renderPalette();
hydrateAuthorUI();
refreshCategoryUI();
setupEditor(8, blankPixels(8), null);
clearEditorHistory();
renderLibrary();
updateSelectedLabel();
rebuildWorldIndex();
updateSyncStats();
updateGuardrailStats();
hydrateVisualSettingsUI();
scheduleFrame();
}
function wireUI() {
window.addEventListener('resize', () => {
resizeCanvas();
render();
});
els.openEditor.addEventListener('click', () => setDrawerOpen(true));
els.closeEditor.addEventListener('click', () => setDrawerOpen(false));
els.tabs.forEach((button) => {
button.addEventListener('click', () => setTab(button.dataset.tab));
});
els.authorName?.addEventListener('input', () => {
state.authorName = (els.authorName.value || 'Local Artist').trim() || 'Local Artist';
saveState();
renderLibrary();
});
els.settingLights?.addEventListener('change', () => {
visualSettings().enableLights = !!els.settingLights.checked;
saveState();
render();
});
els.settingParticles?.addEventListener('change', () => {
visualSettings().enableParticles = !!els.settingParticles.checked;
if (!visualSettings().enableParticles) clearVisualEffectState();
saveState();
render();
});
els.settingDayNight?.addEventListener('change', () => {
visualSettings().enableDayNight = !!els.settingDayNight.checked;
spriteCache.clear();
saveState();
updateClock();
render();
});
[els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('inspect')));
[els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('place')));
[els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.addEventListener('click', () => setMode('erase')));
[els.zoomOut, els.drawerZoomOut].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom / 1.2)));
[els.zoomIn, els.drawerZoomIn].filter(Boolean).forEach((el) => el.addEventListener('click', () => zoomAt(cw / 2, ch / 2, view.zoom * 1.2)));
[els.resetView, els.drawerResetView].filter(Boolean).forEach((el) => el.addEventListener('click', () => resetView(true)));
els.canvas.addEventListener('pointerdown', onWorldPointerDown);
els.canvas.addEventListener('pointermove', onWorldPointerMove);
els.canvas.addEventListener('pointerup', onWorldPointerUp);
els.canvas.addEventListener('pointercancel', onWorldPointerUp);
els.canvas.addEventListener('wheel', onWorldWheel, { passive: false });
els.canvas.addEventListener('contextmenu', (event) => event.preventDefault());
document.addEventListener('contextmenu', onDocumentContextMenu);
document.addEventListener('visibilitychange', onVisibilityChange);
els.assetSize.addEventListener('change', () => {
const oldSize = editorSize;
const nextSize = Number(els.assetSize.value);
commitEditorMutation(() => {
const resizedRight = resizePixels(editorPixels, oldSize, nextSize);
const resizedDepth = resizeDepthPixels(depthPixels, oldSize, nextSize);
setupEditor(nextSize, resizedRight, null, resizedDepth);
doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) };
lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize);
resetEditorView();
return true;
});
updateSettingsSummary();
});
els.assetCategory.addEventListener('change', refreshCategoryUI);
document.querySelectorAll('[data-static-kind]').forEach((button) => {
button.addEventListener('click', () => {
staticKind = button.dataset.staticKind;
document.querySelectorAll('[data-static-kind]').forEach((b) => b.classList.toggle('active', b === button));
refreshCategoryUI();
});
});
document.querySelectorAll('[data-dynamic-kind]').forEach((button) => {
button.addEventListener('click', () => {
dynamicKind = button.dataset.dynamicKind;
document.querySelectorAll('[data-dynamic-kind]').forEach((b) => b.classList.toggle('active', b === button));
refreshCategoryUI();
});
});
els.editRight.addEventListener('click', () => setEditingSide('right'));
els.editLeft.addEventListener('click', () => setEditingSide('left'));
els.toolBrush.addEventListener('click', () => setPaintTool('brush'));
els.toolErase.addEventListener('click', () => setPaintTool('erase'));
els.toolFill?.addEventListener('click', () => setPaintTool('fill'));
els.toolPick?.addEventListener('click', () => setPaintTool('pick'));
els.toolLine?.addEventListener('click', () => setPaintTool('line'));
els.toolRect?.addEventListener('click', () => setPaintTool('rect'));
els.toolSelect?.addEventListener('click', () => setPaintTool('select'));
els.toolLight.addEventListener('click', () => setPaintTool('light'));
els.toolDoor.addEventListener('click', () => setPaintTool('door'));
els.toolDepth?.addEventListener('click', () => setPaintTool('depth'));
els.depthHigh?.addEventListener('click', () => setDepthPaintMode(1));
els.depthLow?.addEventListener('click', () => setDepthPaintMode(-1));
els.depthClear?.addEventListener('click', () => setDepthPaintMode(0));
els.toggleAdvanced?.addEventListener('click', () => {
advancedDraw = !advancedDraw;
toggleHidden(els.toolLight, !advancedDraw);
toggleHidden(els.toolDepth, !advancedDraw);
toggleHidden(els.depthHigh, !advancedDraw);
toggleHidden(els.depthLow, !advancedDraw);
toggleHidden(els.depthClear, !advancedDraw);
toggleHidden(els.advancedHint, !advancedDraw);
els.toggleAdvanced.classList.toggle('active', advancedDraw);
if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush');
drawEditor();
});
els.clearPaint.addEventListener('click', () => {
commitEditorMutation(() => {
editorPixels = blankPixels(editorSize);
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
depthPixels = blankPixels(editorSize).map(() => 0);
lightPixels = [];
editorSelection = null;
return true;
});
});
els.flipHorizontal?.addEventListener('click', flipEditorHorizontal);
els.flipVertical?.addEventListener('click', flipEditorVertical);
els.outlinePaint?.addEventListener('click', applyEditorOutline);
els.clearSelection?.addEventListener('click', () => { editorSelection = null; drawEditor(); updateEditorSelectionButtons(); });
els.nudgeLeft?.addEventListener('click', () => nudgeEditor(-1, 0));
els.nudgeRight?.addEventListener('click', () => nudgeEditor(1, 0));
els.nudgeUp?.addEventListener('click', () => nudgeEditor(0, -1));
els.nudgeDown?.addEventListener('click', () => nudgeEditor(0, 1));
els.exportPng?.addEventListener('click', exportEditorPng);
els.importPng?.addEventListener('click', () => els.pngImportInput?.click());
els.pngImportInput?.addEventListener('change', importEditorPng);
els.undoPaint?.addEventListener('click', undoEditor);
els.redoPaint?.addEventListener('click', redoEditor);
window.addEventListener('keydown', onEditorKeyDown);
els.voteUp?.addEventListener('click', () => voteSelected(1));
els.voteDown?.addEventListener('click', () => voteSelected(-1));
els.bubbleRemix?.addEventListener('click', () => remixSelected());
els.bubbleReport?.addEventListener('click', () => openReportDialog());
els.reportCancel?.addEventListener('click', () => closeReportDialog());
els.reportSubmit?.addEventListener('click', () => submitReportDialog());
els.reportDialog?.addEventListener('click', (event) => { if (event.target === els.reportDialog) closeReportDialog(); });
els.bubbleHide?.addEventListener('click', () => hideSelected());
els.paintCanvas.addEventListener('pointerdown', onPaintPointerDown);
els.paintCanvas.addEventListener('pointermove', onPaintPointerMove);
els.paintCanvas.addEventListener('pointerup', onPaintPointerUp);
els.paintCanvas.addEventListener('pointercancel', onPaintPointerUp);
els.paintCanvas.addEventListener('mousedown', onPaintMouseDown);
window.addEventListener('mousemove', onPaintMouseMove);
window.addEventListener('mouseup', onPaintMouseUp);
els.paintCanvas.addEventListener('wheel', onPaintWheel, { passive: false });
els.paintCanvas.addEventListener('click', onPaintClick);
window.addEventListener('pointerup', () => { isPainting = false; lastPaintedKey = ''; editorPointer.panning = false; finishEditorGesture(); });
els.paintCanvas.addEventListener('contextmenu', (event) => event.preventDefault());
els.saveAsset.addEventListener('click', saveAssetFromEditor);
els.saveAndPlace.addEventListener('click', saveAndPlaceFromEditor);
els.newAsset.addEventListener('click', newAsset);
els.exportData.addEventListener('click', exportData);
els.importData.addEventListener('click', importData);
els.exportCompact?.addEventListener('click', exportCompactData);
els.exportSnapshot?.addEventListener('click', exportSnapshotData);
els.exportAssetBundle?.addEventListener('click', exportAssetBundleData);
els.resetAll.addEventListener('click', resetAll);
els.validateWorld?.addEventListener('click', validateWorld);
els.exportModerationReport?.addEventListener('click', exportModerationReport);
els.clearReports?.addEventListener('click', clearModerationReports);
els.showHiddenAssets?.addEventListener('click', () => {
els.hiddenAssetPanel.hidden = !els.hiddenAssetPanel.hidden;
renderHiddenAssets();
});
}
function resizeCanvas() {
dpr = window.devicePixelRatio || 1;
cw = Math.max(1, window.innerWidth);
ch = Math.max(1, window.innerHeight);
els.canvas.width = Math.floor(cw * dpr);
els.canvas.height = Math.floor(ch * dpr);
els.canvas.style.width = `${cw}px`;
els.canvas.style.height = `${ch}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function resetView(announce) {
const bounds = terrainCache.bounds;
view.zoom = Math.min(1.05, Math.max(MIN_ZOOM, Math.min(cw / bounds.w, ch / bounds.h) * 1.18));
view.x = cw / 2 - (bounds.x + bounds.w / 2) * view.zoom;
view.y = ch / 2 - (bounds.y + bounds.h / 2) * view.zoom + 34;
if (announce) toast('View reset.');
}
function setDrawerOpen(open) {
els.drawer.classList.toggle('open', open);
els.openEditor.hidden = open;
if (!open) {
selectedObject = null;
updateSelectionBubble(performance.now());
}
}
function onDocumentContextMenu(event) {
if (!els.drawer?.classList.contains('open')) return;
if (els.drawer.contains(event.target) || els.openEditor.contains(event.target)) return;
event.preventDefault();
setDrawerOpen(false);
}
function setTab(name) {
els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name));
els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`));
}
function setMode(nextMode) {
mode = nextMode;
const isInspect = mode === 'inspect';
const isPlace = mode === 'place';
const isErase = mode === 'erase';
[els.modeInspect, els.drawerInspect].filter(Boolean).forEach((el) => el.classList.toggle('active', isInspect));
[els.modePlace, els.drawerPlace].filter(Boolean).forEach((el) => el.classList.toggle('active', isPlace));
[els.modeErase, els.drawerErase].filter(Boolean).forEach((el) => el.classList.toggle('active', isErase));
els.canvas.classList.toggle('placeCursor', isPlace);
els.canvas.classList.toggle('eraseCursor', isErase);
}
function refreshCategoryUI() {
const role = currentRole();
const isStatic = roleToCategory(role) === 'static';
if (isStatic) staticKind = roleToSubtype(role);
else dynamicKind = roleToSubtype(role);
toggleHidden(els.staticKindWrap, true);
toggleHidden(els.dynamicKindWrap, true);
toggleHidden(els.sideSwitcher, isStatic);
toggleHidden(els.staticSettingsPanel, !isStatic);
toggleHidden(els.dynamicSettingsPanel, true);
toggleHidden(els.toolLight, !advancedDraw);
toggleHidden(els.toolDepth, !advancedDraw);
toggleHidden(els.toolDoor, role !== 'building');
toggleHidden(els.doorMarkerHint, role !== 'building');
if (!advancedDraw && (paintTool === 'light' || paintTool === 'depth')) setPaintTool('brush');
if (!isStatic && paintTool === 'door') setPaintTool('brush');
if (isStatic && role !== 'building' && paintTool === 'door') setPaintTool('brush');
if (isStatic) editingSide = 'right';
updateRoleHint();
updateSideButtons();
clampEditorView();
drawEditor();
updateSettingsSummary();
}
function updateRoleHint() {
if (!els.roleHint) return;
const role = currentRole();
const textMap = {
human: 'Humans need ▶ Right and ◀ Left sprites. They hop often and visit buildings.',
animal: 'Animals need ▶ Right and ◀ Left sprites. They hop often and prefer nature.',
nature: 'Nature attracts animals and birds. Static sprites are drawn at 2× scale.',
building: 'Buildings attract humans. Use Door to mark the entrance.',
ship: 'Ships are static water objects. They float and emit ring ripples.',
other: 'Other objects are neutral scenery and render at 2× scale.'
};
els.roleHint.textContent = textMap[role] || textMap.other;
}
function updateSettingsSummary() {
if (!els.settingsSummary) return;
const role = currentRole();
if (roleToCategory(role) === 'static') {
const lightCount = lightPixels.length;
const lightText = lightCount ? `${lightCount} lamp cell${lightCount === 1 ? '' : 's'}` : 'no light';
const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : '';
els.settingsSummary.textContent = `${cap(role)} / 2× static pixels / ${lightText}${doorText}.`;
} else {
els.settingsSummary.textContent = `${cap(role)} / canonical ▶ Right / ◀ Left is an auto-mirrored preview and movement-facing state.`;
}
}
function setPaintTool(tool) {
paintTool = tool;
[els.toolBrush, els.toolErase, els.toolFill, els.toolPick, els.toolLine, els.toolRect, els.toolSelect, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active'));
({ brush: els.toolBrush, erase: els.toolErase, fill: els.toolFill, pick: els.toolPick, line: els.toolLine, rect: els.toolRect, select: els.toolSelect, light: els.toolLight, door: els.toolDoor, depth: els.toolDepth }[tool])?.classList.add('active');
const hints = {
brush: 'Draw pixels with the selected palette color.',
erase: 'Erase pixels. Erasing also clears light markers on that cell.',
fill: 'Fill a connected area with the selected palette color. Hold Shift to fill with transparency.',
pick: 'Pick a color from the canvas and return to Draw.',
line: 'Drag to draw a straight line. Right-click erases along the line.',
rect: 'Drag to draw a rectangle. Hold Shift for a filled rectangle; right-click erases.',
select: 'Drag a rectangle to select pixels, then drag or nudge the selection.',
light: 'Paint light cells with the selected palette color. Hold Shift to erase light cells.',
door: 'Click one pixel to mark a building door. Humans will enter near this point.',
depth: 'Advanced: paint high or low depth. Use High/Low/Clear buttons; Shift clears.'
};
els.editHint.textContent = hints[tool] || hints.brush;
[els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active'));
if (tool === 'depth') ({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active');
}
function setDepthPaintMode(mode) {
depthPaintMode = mode === -1 ? -1 : mode === 0 ? 0 : 1;
[els.depthHigh, els.depthLow, els.depthClear].filter(Boolean).forEach((button) => button.classList.remove('active'));
({ 1: els.depthHigh, '-1': els.depthLow, 0: els.depthClear }[depthPaintMode])?.classList.add('active');
if (paintTool !== 'depth') setPaintTool('depth');
const label = depthPaintMode > 0 ? 'high' : depthPaintMode < 0 ? 'low' : 'clear';
if (els.editHint) els.editHint.textContent = `Depth mode: ${label}. Left-drag paints this depth value.`;
}
function setEditingSide(side) {
if (roleToCategory(currentRole()) !== 'dynamic') return;
editingSide = side;
updateSideButtons();
drawEditor();
}
function updateSideButtons() {
if (els.editRight) els.editRight.classList.toggle('active', editingSide === 'right');
if (els.editLeft) els.editLeft.classList.toggle('active', editingSide === 'left');
updateSettingsSummary();
}
function onWorldPointerDown(event) {
event.preventDefault();
if (event.button === 2) {
clearWorldSelection(false);
pointer.down = false;
pointer.id = null;
return;
}
els.canvas.setPointerCapture(event.pointerId);
pointer = {
down: true,
id: event.pointerId,
startX: event.clientX,
startY: event.clientY,
lastX: event.clientX,
lastY: event.clientY,
dragging: false,
downTime: performance.now(),
button: event.button || 0
};
els.canvas.classList.add('dragging');
}
function onWorldPointerMove(event) {
const pos = getCanvasPoint(event);
hoverTile = screenToTile(pos.x, pos.y);
updateTileInfo();
if (!pointer.down || pointer.id !== event.pointerId) return;
const dx = event.clientX - pointer.lastX;
const dy = event.clientY - pointer.lastY;
const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY);
if (total > 3) pointer.dragging = true;
if (pointer.dragging) {
view.x += dx;
view.y += dy;
}
pointer.lastX = event.clientX;
pointer.lastY = event.clientY;
}
function onWorldPointerUp(event) {
if (!pointer.down || pointer.id !== event.pointerId) return;
els.canvas.classList.remove('dragging');
const total = Math.hypot(event.clientX - pointer.startX, event.clientY - pointer.startY);
const elapsed = performance.now() - pointer.downTime;
const wasClick = total < 6 && elapsed < 600;
const pos = getCanvasPoint(event);
const tile = screenToTile(pos.x, pos.y);
if (wasClick) {
const pickedObject = pickObjectAtScreen(pos.x, pos.y, performance.now());
if (mode === 'place' && tile) placeSelected(tile.x, tile.y);
else if (mode === 'erase') {
if (pickedObject) eraseObject(pickedObject.kind, pickedObject.id);
else if (tile) eraseAt(tile.x, tile.y);
} else if (pickedObject) {
selectWorldObject(pickedObject.kind, pickedObject.id, pickedObject.assetId, performance.now());
const asset = findAsset(pickedObject.assetId);
toast(asset ? `Selected ${asset.name}.` : 'Selected object.');
} else if (tile) inspectAt(tile.x, tile.y);
}
pointer.down = false;
pointer.id = null;
pointer.dragging = false;
}
function onWorldWheel(event) {
event.preventDefault();
const pos = getCanvasPoint(event);
const factor = event.deltaY > 0 ? 1 / 1.13 : 1.13;
zoomAt(pos.x, pos.y, view.zoom * factor);
}
function zoomAt(screenX, screenY, nextZoom) {
nextZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
const worldX = (screenX - view.x) / view.zoom;
const worldY = (screenY - view.y) / view.zoom;
view.zoom = nextZoom;
view.x = screenX - worldX * view.zoom;
view.y = screenY - worldY * view.zoom;
}
function getCanvasPoint(event) {
const rect = els.canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
}
function screenToTile(screenX, screenY) {
const worldX = (screenX - view.x) / view.zoom;
const worldY = (screenY - view.y) / view.zoom;
const localX = worldX - ORIGIN_X;
const localY = worldY - ORIGIN_Y;
const a = localX / (TILE_W / 2);
const b = localY / (TILE_H / 2);
const baseX = Math.floor((a + b) / 2);
const baseY = Math.floor((b - a) / 2);
let best = null;
let bestScore = -Infinity;
for (let ty = baseY - 2; ty <= baseY + 2; ty++) {
for (let tx = baseX - 2; tx <= baseX + 2; tx++) {
if (tx < 0 || ty < 0 || tx >= WORLD_W || ty >= WORLD_H) continue;
const tile = world.get(tx, ty);
const center = tileToWorld(tx, ty);
const lift = getTileLift(tile);
const nx = Math.abs(worldX - center.x) / (TILE_W / 2);
const ny = Math.abs(worldY - (center.y + TILE_H / 2 - lift)) / (TILE_H / 2);
if (nx + ny <= 1) {
const score = lift * 100 + (tx + ty);
if (score > bestScore) {
bestScore = score;
best = { x: tx, y: ty, tile };
}
}
}
}
return best;
}
function tileToWorld(x, y) {
return {
x: (x - y) * TILE_W / 2 + ORIGIN_X,
y: (x + y) * TILE_H / 2 + ORIGIN_Y
};
}
function tileKey(x, y) {
return `${x},${y}`;
}
function makeEmptyWorldIndex() {
return { placedByTile: new Map(), dynamicByHomeTile: new Map() };
}
function pushIndexBucket(map, key, item) {
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
function rebuildWorldIndex() {
const next = makeEmptyWorldIndex();
for (const placed of state.placed || []) {
pushIndexBucket(next.placedByTile, tileKey(placed.x, placed.y), placed);
}
for (const summon of state.dynamicSummons || []) {
pushIndexBucket(next.dynamicByHomeTile, tileKey(Math.round(summon.homeX), Math.round(summon.homeY)), summon);
}
worldIndex = next;
}
function getPlacedAtTile(x, y) {
return worldIndex.placedByTile.get(tileKey(x, y)) || [];
}
function getDynamicHomesAtTile(x, y) {
return worldIndex.dynamicByHomeTile.get(tileKey(x, y)) || [];
}
function getDrawableItems(time = performance.now(), viewport = null) {
const rect = viewport || getViewportWorldRect(VIEW_CULL_MARGIN);
const items = [];
for (const placed of state.placed) {
const asset = findAsset(placed.assetId);
if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue;
const itemX = placed.x + .5;
const itemY = placed.y + .5;
if (!isApproxVisible(asset, itemX, itemY, rect)) continue;
items.push({ kind: 'static', asset, x: itemX, y: itemY, source: placed });
}
for (const runtime of dynamicRuntime) {
const asset = findAsset(runtime.assetId);
if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[runtime.id]) continue;
if (time < runtime.hiddenUntil) continue;
if (!isApproxVisible(asset, runtime.x, runtime.y, rect)) continue;
items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime });
}
items.sort(drawOrderCompare);
return items;
}
function drawOrderCompare(a, b) {
return (a.x + a.y) - (b.x + b.y) || a.y - b.y || String(a.source?.id || '').localeCompare(String(b.source?.id || ''));
}
function getViewportWorldRect(margin = 0) {
const invZoom = 1 / Math.max(0.0001, view.zoom);
return {
left: -view.x * invZoom - margin,
top: -view.y * invZoom - margin,
right: (cw - view.x) * invZoom + margin,
bottom: (ch - view.y) * invZoom + margin
};
}
function isApproxVisible(asset, x, y, rect) {
if (!rect) return true;
const pos = tileToWorld(x, y);
if (!(asset.category === 'dynamic' && asset.subtype === 'bird')) pos.y -= getLiftAtCoord(x, y);
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
const w = asset.size * scale + 96;
const h = asset.size * scale + 128;
return pos.x + w >= rect.left && pos.x - w <= rect.right && pos.y + 80 >= rect.top && pos.y - h <= rect.bottom;
}
function pickObjectAtScreen(screenX, screenY, time = performance.now()) {
const worldX = (screenX - view.x) / view.zoom;
const worldY = (screenY - view.y) / view.zoom;
const items = getDrawableItems(time, getViewportWorldRect(256));
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
const info = getSpriteDrawInfo(item, time, false);
if (isSpritePixelHit(item.asset, info, worldX, worldY)) {
return { kind: item.kind, id: item.source.id, assetId: item.asset.id };
}
}
return null;
}
function isSpritePixelHit(asset, info, worldX, worldY) {
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
let localX;
let localY;
if (info.angle) {
const pivotX = info.drawX + info.sprite.width / 2;
const pivotY = info.drawY + info.sprite.height * 0.8;
const dx = worldX - pivotX;
const dy = worldY - pivotY;
const cos = Math.cos(-info.angle);
const sin = Math.sin(-info.angle);
localX = dx * cos - dy * sin + info.sprite.width / 2;
localY = dx * sin + dy * cos + info.sprite.height * 0.8;
} else {
localX = worldX - info.drawX;
localY = worldY - info.drawY;
}
if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false;
const px = Math.floor(localX / scale);
const py = Math.floor(localY / scale);
if (px < 0 || py < 0 || px >= asset.size || py >= asset.size) return false;
const pixels = getAssetPixels(asset, info.side || 'right');
return Boolean(pixels[py * asset.size + px]);
}
function updateTileInfo() {
if (!els.tileInfo) return;
if (!hoverTile) {
els.tileInfo.textContent = 'Move over the map.';
return;
}
const tile = world.get(hoverTile.x, hoverTile.y);
const staticCount = getPlacedAtTile(hoverTile.x, hoverTile.y).length;
const dynamicCount = getDynamicHomesAtTile(hoverTile.x, hoverTile.y).length;
els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`;
}
function clearWorldSelection(announce = false) {
selectedObject = null;
updateSelectionBubble(performance.now());
}
function inspectAt(x, y) {
const tile = world.get(x, y);
const staticObjects = getPlacedAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]);
const dynamicObjects = getDynamicHomesAtTile(x, y).filter((p) => !state.hiddenObjects?.[p.id]);
// Object selection is now based on actual opaque sprite pixels only.
// A plain tile click updates information but does not select hidden/transparent areas.
selectedObject = null;
updateSelectionBubble(performance.now());
const staticNames = staticObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean);
const dynamicNames = dynamicObjects.map((p) => findAsset(p.assetId)?.name).filter(Boolean);
const lines = [`Tile ${x}, ${y}`, `Terrain: ${cap(tile.type)}`];
if (staticNames.length) lines.push(`Static here: ${staticNames.join(', ')}`);
if (dynamicNames.length) lines.push(`Dynamic homes: ${dynamicNames.join(', ')}`);
if (els.tileInfo) els.tileInfo.textContent = lines.join('\n');
}
function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) {
selectedObject = { kind, id: objectId, assetId, selectedAt };
selectedAssetId = assetId;
updateSelectedLabel();
renderLibrary();
updateSelectionBubble(selectedAt);
}
function placeSelected(x, y) {
const asset = findAsset(selectedAssetId);
if (!asset) {
toast('Select an asset first.');
setDrawerOpen(true);
setTab('library');
return;
}
const tile = world.get(x, y);
if (!canPlace(asset, tile)) return;
const existingObject = asset.category === 'static'
? state.placed.find((p) => p.assetId === asset.id)
: state.dynamicSummons.find((p) => p.assetId === asset.id);
const isNewObject = !existingObject;
if (isNewObject && getWorldObjectCount() >= PHASE5_GUARDRAILS.maxWorldObjects) {
toast(`World object limit reached (${PHASE5_GUARDRAILS.maxWorldObjects}). Remove objects before summoning more.`);
return;
}
if (asset.category === 'static') {
const existing = state.placed.find((p) => p.assetId === asset.id);
if (existing) {
existing.x = x; existing.y = y; existing.placedAt = Date.now(); existing.version = (existing.version || 1) + 1;
selectWorldObject('static', existing.id, asset.id, performance.now());
toast(`${asset.name} moved.`);
} else {
const placed = { id: uid(), assetId: asset.id, x, y, placedAt: Date.now(), version: 1 };
state.placed.push(placed);
selectWorldObject('static', placed.id, asset.id, performance.now());
toast(`${asset.name} placed.`);
}
} else {
const existing = state.dynamicSummons.find((p) => p.assetId === asset.id);
if (existing) {
existing.homeX = x; existing.homeY = y; existing.createdAt = Date.now(); existing.version = (existing.version || 1) + 1;
toast(`${asset.name} moved.`);
} else {
const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now(), version: 1 };
state.dynamicSummons.push(summon);
toast(`${asset.name} summoned.`);
}
hydrateRuntime();
const found = state.dynamicSummons.find((p) => p.assetId === asset.id);
if (found) selectWorldObject('dynamic', found.id, asset.id, performance.now());
}
const object = asset.category === 'static'
? state.placed.find((p) => p.assetId === asset.id)
: state.dynamicSummons.find((p) => p.assetId === asset.id);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectUpsertEvent?.(asset.category === 'dynamic' ? 'dynamic' : 'static', object));
spawnEffects.push({ x: x + .5, y: y + .5, started: performance.now() });
saveState();
}
function canPlace(asset, tile) {
if (!tile) return false;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship') && tile.type !== 'water') {
toast('Water/ship objects need water tiles.');
return false;
}
if (asset.category === 'dynamic' && asset.subtype === 'fish' && tile.type !== 'water') {
toast('Fish need water tiles.');
return false;
}
if (asset.category !== 'dynamic' || asset.subtype !== 'fish') {
if (tile.type === 'water' && !(asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship'))) {
toast('Use land for this asset.');
return false;
}
}
return true;
}
function eraseAt(x, y) {
const staticObjects = getPlacedAtTile(x, y);
const staticTarget = staticObjects.at(-1);
if (staticTarget) {
const index = state.placed.findIndex((p) => p.id === staticTarget.id);
if (index >= 0) {
const item = state.placed[index];
const asset = findAsset(item.assetId);
state.placed.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', item.id));
if (selectedObject?.id === item.id) selectedObject = null;
saveState();
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
const dynamicObjects = getDynamicHomesAtTile(x, y);
const dynamicTarget = dynamicObjects.at(-1);
if (dynamicTarget) {
const index = state.dynamicSummons.findIndex((p) => p.id === dynamicTarget.id);
if (index >= 0) {
const item = state.dynamicSummons[index];
const asset = findAsset(item.assetId);
state.dynamicSummons.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', item.id));
if (selectedObject?.id === item.id) selectedObject = null;
hydrateRuntime();
saveState();
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
return;
}
}
toast('Nothing to erase here.');
}
function eraseObject(kind, objectId) {
if (kind === 'static') {
const index = state.placed.findIndex((p) => p.id === objectId);
if (index >= 0) {
const asset = findAsset(state.placed[index].assetId);
state.placed.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('static', objectId));
if (selectedObject?.id === objectId) selectedObject = null;
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
if (kind === 'dynamic') {
const index = state.dynamicSummons.findIndex((p) => p.id === objectId);
if (index >= 0) {
const asset = findAsset(state.dynamicSummons[index].assetId);
state.dynamicSummons.splice(index, 1);
rebuildWorldIndex();
recordSyncEvent(Phase2Sync?.createObjectDeleteEvent?.('dynamic', objectId));
if (selectedObject?.id === objectId) selectedObject = null;
hydrateRuntime();
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
}
}
}
function usesRightButtonAsPaint() {
return paintTool === 'depth' || paintTool === 'light' || paintTool === 'line' || paintTool === 'rect' || paintTool === 'erase';
}
function onPaintPointerDown(event) {
event.preventDefault();
els.paintCanvas.setPointerCapture?.(event.pointerId);
lastPaintedKey = '';
const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint());
editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY, button: event.button || 0 };
isPainting = !isPan;
mousePaint.active = false;
mousePaint.panning = false;
if (isPainting) {
suppressNextPaintClick = true;
suppressMousePaintUntil = performance.now() + 350;
if (handleEditorSpecialPointerDown(event)) return;
beginEditorGesture();
paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey);
if (paintTool === 'fill' || paintTool === 'pick') { isPainting = false; finishEditorGesture(); }
}
}
function onPaintPointerMove(event) {
if (editorPointer.pointerId !== event.pointerId) return;
if (editorPointer.panning) {
event.preventDefault();
const rect = els.paintCanvas.getBoundingClientRect();
const sx = els.paintCanvas.width / rect.width;
const sy = els.paintCanvas.height / rect.height;
editorView.x += (event.clientX - editorPointer.lastX) * sx;
editorView.y += (event.clientY - editorPointer.lastY) * sy;
editorPointer.lastX = event.clientX;
editorPointer.lastY = event.clientY;
clampEditorView();
drawEditor();
return;
}
if (shapeGesture || selectionGesture) {
event.preventDefault();
handleEditorSpecialPointerMove(event);
return;
}
if (!isPainting) return;
if (event.buttons !== undefined) {
const requiredButton = editorPointer?.button === 2 ? 2 : 1;
if ((event.buttons & requiredButton) === 0) return;
}
event.preventDefault();
paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
}
function onPaintPointerUp(event) {
if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false;
if (shapeGesture || selectionGesture) handleEditorSpecialPointerUp(event);
isPainting = false;
lastPaintedKey = '';
finishEditorGesture();
}
function onPaintClick(event) {
if (event.button !== 0) return;
if (suppressNextPaintClick) { suppressNextPaintClick = false; return; }
event.preventDefault();
lastPaintedKey = '';
if (paintTool === 'line' || paintTool === 'rect' || paintTool === 'select') return;
beginEditorGesture();
paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
finishEditorGesture();
}
function onPaintWheel(event) {
event.preventDefault();
const rect = els.paintCanvas.getBoundingClientRect();
const scaleX = els.paintCanvas.width / rect.width;
const scaleY = els.paintCanvas.height / rect.height;
const sx = (event.clientX - rect.left) * scaleX;
const sy = (event.clientY - rect.top) * scaleY;
const worldX = (sx - editorView.x) / editorView.zoom;
const worldY = (sy - editorView.y) / editorView.zoom;
const nextZoom = clamp(editorView.zoom * (event.deltaY > 0 ? 1 / 1.12 : 1.12), 1, 24);
editorView.zoom = nextZoom;
editorView.x = sx - worldX * editorView.zoom;
editorView.y = sy - worldY * editorView.zoom;
clampEditorView();
drawEditor();
}
function onPaintMouseDown(event) {
// Fallback for browsers/extensions where pointer events are swallowed.
if (event.button !== 0 && event.button !== 1 && event.button !== 2) return;
event.preventDefault();
lastPaintedKey = '';
const isPan = event.button === 1 || (event.button === 2 && !usesRightButtonAsPaint());
mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button };
if (performance.now() < suppressMousePaintUntil) return;
if (mousePaint.active) {
beginEditorGesture();
paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey);
if (paintTool === 'fill' || paintTool === 'pick') { mousePaint.active = false; finishEditorGesture(); }
}
}
function onPaintMouseMove(event) {
if (!mousePaint.active && !mousePaint.panning) return;
event.preventDefault();
if (mousePaint.panning) {
const rect = els.paintCanvas.getBoundingClientRect();
const sx = els.paintCanvas.width / rect.width;
const sy = els.paintCanvas.height / rect.height;
editorView.x += (event.clientX - mousePaint.lastX) * sx;
editorView.y += (event.clientY - mousePaint.lastY) * sy;
mousePaint.lastX = event.clientX;
mousePaint.lastY = event.clientY;
clampEditorView();
drawEditor();
return;
}
paintAtClient(event.clientX, event.clientY, mousePaint.button || 0, event.shiftKey);
}
function onPaintMouseUp() {
mousePaint.active = false;
mousePaint.panning = false;
lastPaintedKey = '';
finishEditorGesture();
}
function paintAtEvent(event) {
paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey);
}
function paintAtClient(clientX, clientY, button = 0, shiftKey = false) {
const local = clientToPaintLocal(clientX, clientY);
const x = Math.floor(local.x);
const y = Math.floor(local.y);
if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return;
const key = `${x},${y},${paintTool},${button},${shiftKey}`;
if (key === lastPaintedKey && paintTool !== 'fill') return;
lastPaintedKey = key;
const point = displayCellToCanonical(x, y);
const index = point.y * editorSize + point.x;
let changed = false;
if (paintTool === 'pick') {
const picked = editorPixels[index];
if (picked) selectPaletteCode(picked);
setPaintTool('brush');
drawEditor();
return;
}
if (paintTool === 'fill') {
const displayPixels = getDisplayEditorPixels();
const fillColor = shiftKey ? null : selectedColorCode;
const nextDisplay = EditorActions?.floodFill
? EditorActions.floodFill(displayPixels, editorSize, x, y, fillColor)
: fallbackFloodFill(displayPixels, editorSize, x, y, fillColor);
if (nextDisplay.changed) {
editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
? mirrorPixels(nextDisplay.pixels, editorSize)
: nextDisplay.pixels;
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
if (fillColor === null && Array.isArray(nextDisplay.cells)) {
for (const cell of nextDisplay.cells) {
const src = displayCellToCanonical(cell.x, cell.y);
removeLightPixel(src.x, src.y);
}
}
changed = true;
}
} else if (paintTool === 'brush') {
if (editorPixels[index] !== selectedColorCode) {
editorPixels[index] = selectedColorCode;
changed = true;
}
} else if (paintTool === 'erase') {
if (editorPixels[index] !== null) {
editorPixels[index] = null;
changed = true;
}
const before = lightPixels.length;
removeLightPixel(point.x, point.y);
changed = changed || before !== lightPixels.length;
} else if (paintTool === 'light') {
const before = JSON.stringify(lightPixels);
if (shiftKey || button === 2) removeLightPixel(point.x, point.y);
else addLightPixel(point.x, point.y, selectedColorCode);
changed = JSON.stringify(lightPixels) !== before;
updateSettingsSummary();
} else if (paintTool === 'depth') {
const next = shiftKey ? 0 : (button === 2 ? -1 : depthPaintMode);
if (depthPixels[index] !== next) {
depthPixels[index] = next;
changed = true;
}
} else if (paintTool === 'door') {
if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') {
if (doorPixel.x !== x || doorPixel.y !== y) {
doorPixel = { x, y };
changed = true;
}
updateSettingsSummary();
}
}
if (changed) {
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
markEditorChanged();
drawEditor();
}
}
function paintEventToCell(event) {
const local = paintEventToLocal(event);
return { x: Math.floor(local.x), y: Math.floor(local.y) };
}
function paintEventToLocal(event) {
return clientToPaintLocal(event.clientX, event.clientY);
}
function clientToPaintLocal(clientX, clientY) {
const rect = els.paintCanvas.getBoundingClientRect();
const scaleX = els.paintCanvas.width / rect.width;
const scaleY = els.paintCanvas.height / rect.height;
const sx = (clientX - rect.left) * scaleX;
const sy = (clientY - rect.top) * scaleY;
const cell = els.paintCanvas.width / editorSize;
return {
x: (sx - editorView.x) / editorView.zoom / cell,
y: (sy - editorView.y) / editorView.zoom / cell
};
}
function snapshotEditorState() {
return {
size: editorSize,
rightPixels: [...editorPixels],
depthPixels: [...depthPixels],
lightPixels: lightPixels.map((p) => ({ ...p })),
doorPixel: { ...doorPixel },
editingSide,
selectedColorCode,
editorSelection: editorSelection ? { ...editorSelection } : null
};
}
function restoreEditorState(snapshot) {
editorSize = snapshot.size || editorSize;
editorPixels = normalizePixels(snapshot.rightPixels || [], editorSize);
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
depthPixels = normalizeDepthPixels(snapshot.depthPixels || [], editorSize);
lightPixels = Array.isArray(snapshot.lightPixels) ? snapshot.lightPixels.map((p) => ({ ...p })) : [];
doorPixel = snapshot.doorPixel ? { ...snapshot.doorPixel } : { x: Math.floor(editorSize / 2), y: editorSize - 1 };
editingSide = snapshot.editingSide || editingSide;
if (snapshot.selectedColorCode) selectedColorCode = snapshot.selectedColorCode;
editorSelection = snapshot.editorSelection ? normalizeSelectionRect(snapshot.editorSelection.x, snapshot.editorSelection.y, snapshot.editorSelection.x + snapshot.editorSelection.w - 1, snapshot.editorSelection.y + snapshot.editorSelection.h - 1) : null;
els.assetSize.value = String(editorSize);
updateSideButtons();
renderPalette();
drawEditor();
updateSettingsSummary();
updateEditorHistoryButtons();
updateEditorSelectionButtons();
}
function beginEditorGesture() {
if (!editorGestureSnapshot) {
editorGestureSnapshot = snapshotEditorState();
editorGestureChanged = false;
}
}
function markEditorChanged() {
editorGestureChanged = true;
}
function finishEditorGesture() {
if (!editorGestureSnapshot) return;
if (editorGestureChanged) {
editorHistory.push(editorGestureSnapshot);
if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift();
editorFuture = [];
}
editorGestureSnapshot = null;
editorGestureChanged = false;
updateEditorHistoryButtons();
}
function commitEditorMutation(mutator) {
const before = snapshotEditorState();
const changed = Boolean(mutator());
if (changed) {
editorHistory.push(before);
if (editorHistory.length > EDITOR_HISTORY_LIMIT) editorHistory.shift();
editorFuture = [];
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
drawEditor();
updateSettingsSummary();
updateEditorHistoryButtons();
}
return changed;
}
function clearEditorHistory() {
editorHistory = [];
editorFuture = [];
editorGestureSnapshot = null;
editorGestureChanged = false;
updateEditorHistoryButtons();
}
function undoEditor() {
if (!editorHistory.length) return;
editorFuture.push(snapshotEditorState());
restoreEditorState(editorHistory.pop());
}
function redoEditor() {
if (!editorFuture.length) return;
editorHistory.push(snapshotEditorState());
restoreEditorState(editorFuture.pop());
}
function updateEditorHistoryButtons() {
if (els.undoPaint) els.undoPaint.disabled = editorHistory.length === 0;
if (els.redoPaint) els.redoPaint.disabled = editorFuture.length === 0;
updateEditorSelectionButtons();
}
function onEditorKeyDown(event) {
if (!els.drawer?.classList.contains('open')) return;
const key = event.key.toLowerCase();
const activeTag = document.activeElement?.tagName?.toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return;
if ((event.ctrlKey || event.metaKey) && key === 'z') {
event.preventDefault();
if (event.shiftKey) redoEditor();
else undoEditor();
} else if ((event.ctrlKey || event.metaKey) && key === 'y') {
event.preventDefault();
redoEditor();
} else if (editorSelection && ['arrowleft', 'arrowright', 'arrowup', 'arrowdown'].includes(key)) {
event.preventDefault();
const step = event.shiftKey ? 4 : 1;
const delta = { arrowleft: [-step, 0], arrowright: [step, 0], arrowup: [0, -step], arrowdown: [0, step] }[key];
moveSelectionBy(delta[0], delta[1]);
} else if (key === 'escape') {
editorSelection = null;
drawEditor();
updateEditorSelectionButtons();
} else if (!event.ctrlKey && !event.metaKey && !event.altKey) {
const shortcuts = { b: 'brush', e: 'erase', f: 'fill', i: 'pick', l: 'line', r: 'rect', s: 'select' };
if (shortcuts[key]) { event.preventDefault(); setPaintTool(shortcuts[key]); }
}
}
function fallbackFloodFill(sourcePixels, size, x, y, colorCode) {
const pixels = [...sourcePixels];
const target = pixels[y * size + x] || null;
const replacement = colorCode || null;
if (target === replacement) return { pixels, changed: false, count: 0, cells: [] };
const stack = [[x, y]];
const cells = [];
while (stack.length) {
const [cx, cy] = stack.pop();
if (cx < 0 || cy < 0 || cx >= size || cy >= size) continue;
const index = cy * size + cx;
if ((pixels[index] || null) !== target) continue;
pixels[index] = replacement;
cells.push({ x: cx, y: cy });
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
}
return { pixels, changed: cells.length > 0, count: cells.length, cells };
}
function handleEditorSpecialPointerDown(event) {
const cell = clientToCell(event.clientX, event.clientY);
if (!cell) return false;
if (paintTool === 'line' || paintTool === 'rect') {
beginEditorGesture();
shapeGesture = {
tool: paintTool,
startX: cell.x,
startY: cell.y,
endX: cell.x,
endY: cell.y,
erase: event.button === 2,
filled: event.shiftKey
};
shapePreview = { ...shapeGesture };
drawEditor();
return true;
}
if (paintTool === 'select') {
const inside = editorSelection && pointInSelection(cell.x, cell.y, editorSelection);
if (inside) {
beginEditorGesture();
const baseSelection = { ...editorSelection };
selectionGesture = {
mode: 'move',
startX: cell.x,
startY: cell.y,
lastDx: 0,
lastDy: 0,
baseSelection,
basePixels: getDisplayEditorPixels(),
baseDepthPixels: getDisplayDepthPixels(),
baseLightPixels: lightPixels.map((p) => ({ ...p }))
};
} else {
selectionGesture = { mode: 'select', startX: cell.x, startY: cell.y, endX: cell.x, endY: cell.y };
editorSelection = normalizeSelectionRect(cell.x, cell.y, cell.x, cell.y);
updateEditorSelectionButtons();
drawEditor();
}
return true;
}
return false;
}
function handleEditorSpecialPointerMove(event) {
const cell = clientToCell(event.clientX, event.clientY);
if (!cell) return;
if (shapeGesture) {
shapeGesture.endX = cell.x;
shapeGesture.endY = cell.y;
shapeGesture.filled = event.shiftKey;
shapePreview = { ...shapeGesture };
drawEditor();
return;
}
if (selectionGesture?.mode === 'select') {
selectionGesture.endX = cell.x;
selectionGesture.endY = cell.y;
editorSelection = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, cell.x, cell.y);
updateEditorSelectionButtons();
drawEditor();
return;
}
if (selectionGesture?.mode === 'move') {
let dx = cell.x - selectionGesture.startX;
let dy = cell.y - selectionGesture.startY;
({ dx, dy } = clampSelectionDelta(selectionGesture.baseSelection, dx, dy));
if (dx === selectionGesture.lastDx && dy === selectionGesture.lastDy) return;
selectionGesture.lastDx = dx;
selectionGesture.lastDy = dy;
applySelectionMoveFromBase(selectionGesture.basePixels, selectionGesture.baseDepthPixels, selectionGesture.baseLightPixels, selectionGesture.baseSelection, dx, dy);
markEditorChanged();
drawEditor();
}
}
function handleEditorSpecialPointerUp(event) {
if (shapeGesture) {
const changed = commitShapeGesture(shapeGesture);
if (changed) markEditorChanged();
shapeGesture = null;
shapePreview = null;
drawEditor();
return;
}
if (selectionGesture?.mode === 'select') {
const rect = normalizeSelectionRect(selectionGesture.startX, selectionGesture.startY, selectionGesture.endX, selectionGesture.endY);
editorSelection = rect && rect.w * rect.h > 0 ? rect : null;
selectionGesture = null;
drawEditor();
updateEditorSelectionButtons();
return;
}
if (selectionGesture?.mode === 'move') {
selectionGesture = null;
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
updateEditorSelectionButtons();
return;
}
}
function clientToCell(clientX, clientY) {
const local = clientToPaintLocal(clientX, clientY);
const x = Math.floor(local.x);
const y = Math.floor(local.y);
if (x < 0 || y < 0 || x >= editorSize || y >= editorSize) return null;
return { x, y };
}
function normalizeSelectionRect(x0, y0, x1, y1) {
const left = clamp(Math.min(x0, x1), 0, editorSize - 1);
const top = clamp(Math.min(y0, y1), 0, editorSize - 1);
const right = clamp(Math.max(x0, x1), 0, editorSize - 1);
const bottom = clamp(Math.max(y0, y1), 0, editorSize - 1);
return { x: left, y: top, w: right - left + 1, h: bottom - top + 1 };
}
function pointInSelection(x, y, selection) {
return selection && x >= selection.x && y >= selection.y && x < selection.x + selection.w && y < selection.y + selection.h;
}
function clampSelectionDelta(selection, dx, dy) {
if (!selection) return { dx: 0, dy: 0 };
return {
dx: clamp(dx, -selection.x, editorSize - (selection.x + selection.w)),
dy: clamp(dy, -selection.y, editorSize - (selection.y + selection.h))
};
}
function getDisplayDepthPixels() {
const source = normalizeDepthPixels(depthPixels, editorSize);
return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
? mirrorScalarPixels(source, editorSize)
: source;
}
function setDisplayEditorPixels(pixels) {
editorPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
? mirrorPixels(pixels, editorSize)
: normalizePixels(pixels, editorSize);
editorLeftPixels = mirrorPixels(editorPixels, editorSize);
}
function setDisplayDepthPixels(pixels) {
depthPixels = editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
? mirrorScalarPixels(pixels, editorSize)
: normalizeDepthPixels(pixels, editorSize);
}
function mirrorScalarPixels(values, size) {
const source = Array.isArray(values) ? values : [];
const out = Array(size * size).fill(0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
out[y * size + (size - 1 - x)] = source[y * size + x] || 0;
}
}
return out;
}
function applySelectionMoveFromBase(basePixels, baseDepthPixels, baseLightPixels, selection, dx, dy) {
const nextPixels = normalizePixels(basePixels, editorSize);
const nextDepth = normalizeDepthPixels(baseDepthPixels, editorSize);
const movedPixels = [...nextPixels];
const movedDepth = [...nextDepth];
for (let y = selection.y; y < selection.y + selection.h; y++) {
for (let x = selection.x; x < selection.x + selection.w; x++) {
const index = y * editorSize + x;
movedPixels[index] = null;
movedDepth[index] = 0;
}
}
for (let y = selection.y; y < selection.y + selection.h; y++) {
for (let x = selection.x; x < selection.x + selection.w; x++) {
const from = y * editorSize + x;
const to = (y + dy) * editorSize + (x + dx);
movedPixels[to] = nextPixels[from] || null;
movedDepth[to] = nextDepth[from] || 0;
}
}
setDisplayEditorPixels(movedPixels);
setDisplayDepthPixels(movedDepth);
lightPixels = moveDisplayLights(baseLightPixels, selection, dx, dy);
editorSelection = { x: selection.x + dx, y: selection.y + dy, w: selection.w, h: selection.h };
updateEditorSelectionButtons();
}
function moveDisplayLights(sourceLights, selection, dx, dy) {
const out = [];
for (const light of sourceLights || []) {
const shown = canonicalCellToDisplay(light.x, light.y);
if (!pointInSelection(shown.x, shown.y, selection)) {
out.push({ ...light });
continue;
}
const nx = shown.x + dx;
const ny = shown.y + dy;
if (nx < 0 || ny < 0 || nx >= editorSize || ny >= editorSize) continue;
const canonical = displayCellToCanonical(nx, ny);
out.push({ ...light, x: canonical.x, y: canonical.y });
}
return out;
}
function moveSelectionBy(dx, dy) {
if (!editorSelection) return false;
const bounded = clampSelectionDelta(editorSelection, dx, dy);
if (!bounded.dx && !bounded.dy) return false;
return commitEditorMutation(() => {
applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), editorSelection, bounded.dx, bounded.dy);
return true;
});
}
function nudgeEditor(dx, dy) {
if (editorSelection) moveSelectionBy(dx, dy);
else shiftEditorContent(dx, dy);
}
function shiftEditorContent(dx, dy) {
return commitEditorMutation(() => {
const selection = { x: 0, y: 0, w: editorSize, h: editorSize };
const bounded = clampSelectionDelta(selection, dx, dy);
if (!bounded.dx && !bounded.dy) return false;
applySelectionMoveFromBase(getDisplayEditorPixels(), getDisplayDepthPixels(), lightPixels.map((p) => ({ ...p })), selection, bounded.dx, bounded.dy);
editorSelection = null;
doorPixel = { x: clamp(doorPixel.x + bounded.dx, 0, editorSize - 1), y: clamp(doorPixel.y + bounded.dy, 0, editorSize - 1) };
return true;
});
}
function commitShapeGesture(gesture) {
const cells = gesture.tool === 'line'
? getLineCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY)
: getRectCells(gesture.startX, gesture.startY, gesture.endX, gesture.endY, gesture.filled);
if (!cells.length) return false;
const pixels = getDisplayEditorPixels();
const value = gesture.erase ? null : selectedColorCode;
let changed = false;
for (const cell of cells) {
const index = cell.y * editorSize + cell.x;
if ((pixels[index] || null) !== value) {
pixels[index] = value;
changed = true;
}
if (gesture.erase) {
const canonical = displayCellToCanonical(cell.x, cell.y);
removeLightPixel(canonical.x, canonical.y);
}
}
if (!changed) return false;
setDisplayEditorPixels(pixels);
return true;
}
function getLineCells(x0, y0, x1, y1) {
const cells = [];
let dx = Math.abs(x1 - x0);
let dy = -Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
let x = x0;
let y = y0;
while (true) {
cells.push({ x, y });
if (x === x1 && y === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; }
if (e2 <= dx) { err += dx; y += sy; }
}
return cells.filter((cell) => cell.x >= 0 && cell.y >= 0 && cell.x < editorSize && cell.y < editorSize);
}
function getRectCells(x0, y0, x1, y1, filled = false) {
const rect = normalizeSelectionRect(x0, y0, x1, y1);
const cells = [];
for (let y = rect.y; y < rect.y + rect.h; y++) {
for (let x = rect.x; x < rect.x + rect.w; x++) {
if (filled || x === rect.x || y === rect.y || x === rect.x + rect.w - 1 || y === rect.y + rect.h - 1) cells.push({ x, y });
}
}
return cells;
}
function drawEditorOverlays(cell) {
if (shapePreview) {
const cells = shapePreview.tool === 'line'
? getLineCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY)
: getRectCells(shapePreview.startX, shapePreview.startY, shapePreview.endX, shapePreview.endY, shapePreview.filled);
pctx.save();
pctx.globalAlpha = shapePreview.erase ? 0.32 : 0.42;
pctx.fillStyle = shapePreview.erase ? '#ff6b6b' : colorToHex(selectedColorCode);
for (const c of cells) pctx.fillRect(c.x * cell, c.y * cell, Math.ceil(cell), Math.ceil(cell));
pctx.restore();
}
if (editorSelection) {
pctx.save();
pctx.strokeStyle = '#ff4fa3';
pctx.lineWidth = Math.max(2, 2 / editorView.zoom);
pctx.setLineDash([Math.max(3, 6 / editorView.zoom), Math.max(3, 4 / editorView.zoom)]);
pctx.strokeRect(editorSelection.x * cell + 1 / editorView.zoom, editorSelection.y * cell + 1 / editorView.zoom, editorSelection.w * cell - 2 / editorView.zoom, editorSelection.h * cell - 2 / editorView.zoom);
pctx.fillStyle = 'rgba(255, 79, 163, .08)';
pctx.fillRect(editorSelection.x * cell, editorSelection.y * cell, editorSelection.w * cell, editorSelection.h * cell);
pctx.restore();
}
}
function updateEditorSelectionButtons() {
const disabled = !editorSelection;
if (els.clearSelection) els.clearSelection.disabled = disabled;
}
function flipEditorHorizontal() {
commitEditorMutation(() => {
editorPixels = mirrorPixels(editorPixels, editorSize);
depthPixels = mirrorScalarPixels(depthPixels, editorSize);
lightPixels = lightPixels.map((p) => ({ ...p, x: editorSize - 1 - p.x }));
doorPixel = { ...doorPixel, x: editorSize - 1 - doorPixel.x };
editorSelection = editorSelection ? { x: editorSize - (editorSelection.x + editorSelection.w), y: editorSelection.y, w: editorSelection.w, h: editorSelection.h } : null;
return true;
});
}
function flipEditorVertical() {
commitEditorMutation(() => {
editorPixels = flipPixelsVertical(editorPixels, editorSize);
depthPixels = flipScalarPixelsVertical(depthPixels, editorSize);
lightPixels = lightPixels.map((p) => ({ ...p, y: editorSize - 1 - p.y }));
doorPixel = { ...doorPixel, y: editorSize - 1 - doorPixel.y };
editorSelection = editorSelection ? { x: editorSelection.x, y: editorSize - (editorSelection.y + editorSelection.h), w: editorSelection.w, h: editorSelection.h } : null;
return true;
});
}
function flipPixelsVertical(pixels, size) {
const source = normalizePixels(pixels, size);
const out = blankPixels(size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || null;
}
return out;
}
function flipScalarPixelsVertical(values, size) {
const source = normalizeDepthPixels(values, size);
const out = Array(size * size).fill(0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) out[(size - 1 - y) * size + x] = source[y * size + x] || 0;
}
return out;
}
function applyEditorOutline() {
commitEditorMutation(() => {
const source = [...editorPixels];
const out = [...editorPixels];
let changed = false;
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const index = y * editorSize + x;
if (source[index]) continue;
const adjacent = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => {
const nx = x + dx;
const ny = y + dy;
return nx >= 0 && ny >= 0 && nx < editorSize && ny < editorSize && source[ny * editorSize + nx];
});
if (adjacent) { out[index] = selectedColorCode; changed = true; }
}
}
if (!changed) return false;
editorPixels = out;
return true;
});
}
function exportEditorPng() {
const scale = Math.max(8, Math.floor(512 / editorSize));
const canvas = document.createElement('canvas');
canvas.width = editorSize * scale;
canvas.height = editorSize * scale;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
const pixels = getDisplayEditorPixels();
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const code = pixels[y * editorSize + x];
if (!code) continue;
c.fillStyle = colorToHex(code);
c.fillRect(x * scale, y * scale, scale, scale);
}
}
const link = document.createElement('a');
const rawName = (els.assetName.value || 'pixel-art').trim().replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'pixel-art';
link.download = `${rawName}-${editorSize}x${editorSize}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
toast('PNG exported.');
}
function importEditorPng(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const img = new Image();
img.onload = () => {
const targetSize = chooseImportCanvasSize(img.naturalWidth || img.width, img.naturalHeight || img.height);
commitEditorMutation(() => {
const previousSize = editorSize;
const canvas = document.createElement('canvas');
canvas.width = targetSize;
canvas.height = targetSize;
const c = canvas.getContext('2d', { willReadFrequently: true });
c.imageSmoothingEnabled = false;
c.clearRect(0, 0, targetSize, targetSize);
const fit = fitImageInsideSquare(img.naturalWidth || img.width, img.naturalHeight || img.height, targetSize);
c.drawImage(img, fit.dx, fit.dy, fit.dw, fit.dh);
const data = c.getImageData(0, 0, targetSize, targetSize).data;
const next = blankPixels(targetSize);
for (let i = 0; i < targetSize * targetSize; i++) {
const alpha = data[i * 4 + 3];
if (alpha < 32) continue;
const hex = rgbToHex(data[i * 4], data[i * 4 + 1], data[i * 4 + 2]);
next[i] = nearestPaletteCode(hex);
}
editorSize = targetSize;
els.assetSize.value = String(targetSize);
setDisplayEditorPixels(next);
depthPixels = blankPixels(targetSize).map(() => 0);
lightPixels = [];
doorPixel = { x: Math.min(doorPixel.x, targetSize - 1), y: Math.min(doorPixel.y, targetSize - 1) };
editorSelection = null;
if (previousSize !== targetSize) resetEditorView();
return true;
});
URL.revokeObjectURL(img.src);
const note = (img.naturalWidth === targetSize && img.naturalHeight === targetSize) ? '' : ` (fit from ${img.naturalWidth}×${img.naturalHeight})`;
toast(`PNG imported as ${targetSize}×${targetSize}${note}.`);
};
img.onerror = () => toast('Could not read that PNG.');
img.src = URL.createObjectURL(file);
}
function chooseImportCanvasSize(width, height) {
const allowed = [8, 16, 32, 64];
const w = Math.max(1, Math.round(Number(width) || 1));
const h = Math.max(1, Math.round(Number(height) || 1));
if (w === h && allowed.includes(w)) return w;
const target = Math.max(w, h);
return allowed.find((size) => target <= size) || allowed[allowed.length - 1];
}
function fitImageInsideSquare(width, height, size) {
const w = Math.max(1, Number(width) || 1);
const h = Math.max(1, Number(height) || 1);
const scale = Math.min(size / w, size / h);
const dw = Math.max(1, Math.round(w * scale));
const dh = Math.max(1, Math.round(h * scale));
return {
dx: Math.floor((size - dw) / 2),
dy: Math.floor((size - dh) / 2),
dw,
dh
};
}
function rgbToHex(r, g, b) {
const part = (value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, '0');
return `#${part(r)}${part(g)}${part(b)}`;
}
function selectPaletteCode(code) {
if (!PALETTE_BY_CODE[code]) return;
selectedColorCode = code;
els.paintColor.value = PALETTE_BY_CODE[code];
renderPalette();
}
function getActiveEditorPixels() {
return editorPixels;
}
function getDisplayEditorPixels() {
return editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic'
? mirrorPixels(editorPixels, editorSize)
: normalizePixels(editorPixels, editorSize);
}
function displayCellToCanonical(x, y) {
if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y };
return { x, y };
}
function canonicalCellToDisplay(x, y) {
if (editingSide === 'left' && roleToCategory(currentRole()) === 'dynamic') return { x: editorSize - 1 - x, y };
return { x, y };
}
function setupEditor(size, rightPixels, leftPixels = null, nextDepthPixels = null) {
editorSize = size;
const normalizedRight = normalizePixels(rightPixels, size);
const normalizedLeft = leftPixels ? normalizePixels(leftPixels, size) : null;
const canonical = hasAnyPixel(normalizedRight) || !normalizedLeft ? normalizedRight : mirrorPixels(normalizedLeft, size);
editorPixels = normalizePixels(canonical, size);
editorLeftPixels = mirrorPixels(editorPixels, size);
editorSelection = null;
depthPixels = Array.isArray(nextDepthPixels) || typeof nextDepthPixels === 'string' ? normalizeDepthPixels(nextDepthPixels, size) : resizeDepthPixels(depthPixels, Math.sqrt(depthPixels.length) || size, size);
els.assetSize.value = String(size);
lightPixels = lightPixels.filter((p) => p.x >= 0 && p.y >= 0 && p.x < size && p.y < size);
doorPixel = { x: clamp(doorPixel.x, 0, size - 1), y: clamp(doorPixel.y, 0, size - 1) };
resetEditorView();
drawEditor();
updateEditorHistoryButtons();
}
function drawEditor() {
clampEditorView();
const canvas = els.paintCanvas;
const rectSize = canvas.width;
const cell = rectSize / editorSize;
pctx.clearRect(0, 0, rectSize, rectSize);
pctx.fillStyle = '#fffaf0';
pctx.fillRect(0, 0, rectSize, rectSize);
pctx.save();
pctx.translate(editorView.x, editorView.y);
pctx.scale(editorView.zoom, editorView.zoom);
const pixels = getDisplayEditorPixels();
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const color = pixels[y * editorSize + x];
if (!color) continue;
pctx.fillStyle = colorToHex(color);
pctx.fillRect(x * cell, y * cell, Math.ceil(cell), Math.ceil(cell));
}
}
if (advancedDraw && depthPixels?.length) {
if (paintTool === 'depth') {
pctx.fillStyle = 'rgba(46, 89, 160, .07)';
pctx.fillRect(0, 0, rectSize, rectSize);
}
for (let y = 0; y < editorSize; y++) {
for (let x = 0; x < editorSize; x++) {
const source = displayCellToCanonical(x, y);
const depth = depthPixels[source.y * editorSize + source.x] || 0;
if (!depth) continue;
const high = depth > 0;
pctx.fillStyle = high ? 'rgba(34, 116, 255, .62)' : 'rgba(88, 44, 120, .62)';
pctx.fillRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76));
pctx.strokeStyle = high ? 'rgba(6, 28, 75, .55)' : 'rgba(35, 13, 54, .55)';
pctx.lineWidth = Math.max(1, 2 / editorView.zoom);
pctx.strokeRect(x * cell + Math.max(1, cell * .12), y * cell + Math.max(1, cell * .12), Math.max(2, cell * .76), Math.max(2, cell * .76));
}
}
}
pctx.strokeStyle = 'rgba(36, 48, 68, .13)';
pctx.lineWidth = 1 / editorView.zoom;
for (let i = 0; i <= editorSize; i++) {
const p = Math.round(i * cell) + .5;
pctx.beginPath(); pctx.moveTo(p, 0); pctx.lineTo(p, rectSize); pctx.stroke();
pctx.beginPath(); pctx.moveTo(0, p); pctx.lineTo(rectSize, p); pctx.stroke();
}
{
for (const light of lightPixels) {
const shown = canonicalCellToDisplay(light.x, light.y);
const lx = (shown.x + .5) * cell;
const ly = (shown.y + .5) * cell;
pctx.fillStyle = hexToRgba(colorToHex(light.c || selectedColorCode), .28);
pctx.beginPath();
pctx.arc(lx, ly, Math.max(4, cell * .20), 0, Math.PI * 2);
pctx.fill();
pctx.strokeStyle = colorToHex(light.c || selectedColorCode);
pctx.lineWidth = 2 / editorView.zoom;
pctx.beginPath();
pctx.arc(lx, ly, Math.max(3, cell * .14), 0, Math.PI * 2);
pctx.stroke();
}
if (currentRole() === 'building') {
pctx.strokeStyle = '#5fb8ff';
pctx.lineWidth = 3 / editorView.zoom;
pctx.strokeRect(doorPixel.x * cell + 2 / editorView.zoom, doorPixel.y * cell + 2 / editorView.zoom, cell - 4 / editorView.zoom, cell - 4 / editorView.zoom);
}
}
drawEditorOverlays(cell);
pctx.restore();
}
function resetEditorView() {
editorView.zoom = 1;
editorView.x = 0;
editorView.y = 0;
clampEditorView();
}
function clampEditorView() {
const size = els.paintCanvas.width;
const scaled = size * editorView.zoom;
const minOffset = size - scaled;
editorView.x = clamp(editorView.x, minOffset, 0);
editorView.y = clamp(editorView.y, minOffset, 0);
}
function addLightPixel(x, y, colorCode = selectedColorCode) {
const point = { x: clamp(Math.floor(Number(x)), 0, editorSize - 1), y: clamp(Math.floor(Number(y)), 0, editorSize - 1), c: colorCode };
const existing = lightPixels.find((p) => p.x === point.x && p.y === point.y);
if (existing) existing.c = colorCode;
else lightPixels.push(point);
}
function removeLightPixel(x, y) {
lightPixels = lightPixels.filter((p) => !(p.x === x && p.y === y));
}
function alignEditorStateToBottom(size) {
const shift = getBottomShift(editorPixels, size);
return {
rightPixels: shiftPixelsVertical(editorPixels, size, shift),
depthPixels: shiftDepthPixelsVertical(depthPixels, size, shift),
lightPixels: lightPixels
.map((p) => ({ ...p, y: p.y + shift }))
.filter((p) => p.x >= 0 && p.x < size && p.y >= 0 && p.y < size),
door: { x: doorPixel.x, y: clamp(doorPixel.y + shift, 0, size - 1) }
};
}
function getBottomShift(pixels, size) {
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (pixels[y * size + x]) maxY = Math.max(maxY, y);
}
}
return maxY < 0 ? 0 : size - 1 - maxY;
}
function shiftPixelsVertical(pixels, size, shift) {
if (!shift) return normalizePixels(pixels, size);
const out = blankPixels(size);
const source = normalizePixels(pixels, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const ny = y + shift;
if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || null;
}
}
return out;
}
function shiftDepthPixelsVertical(pixels, size, shift) {
const source = normalizeDepthPixels(pixels, size);
if (!shift) return source;
const out = Array(size * size).fill(0);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const ny = y + shift;
if (ny >= 0 && ny < size) out[ny * size + x] = source[y * size + x] || 0;
}
}
return out;
}
function saveAssetFromEditor() {
const role = currentRole();
const category = roleToCategory(role);
const subtype = roleToSubtype(role);
const size = editorSize;
const existing = editingAssetId ? findAsset(editingAssetId) : null;
const paintedDots = countPixels(editorPixels);
if (paintedDots < 10) {
toast('Draw at least 10 pixels before saving.');
return null;
}
if (!existing && state.assets.length >= PHASE5_GUARDRAILS.maxAssets) {
toast(`Asset limit reached (${PHASE5_GUARDRAILS.maxAssets}). Delete or hide unused assets before saving more.`);
return null;
}
const name = (els.assetName.value || '').trim() || existing?.name || `${cap(role)} ${state.assets.length + 1}`;
const aligned = alignEditorStateToBottom(size);
const asset = {
id: existing?.id || uid(),
name,
category,
subtype,
size,
createdAt: existing?.createdAt || Date.now(),
updatedAt: Date.now(),
author: existing?.author || state.authorName || 'Local Artist',
parentAssetId: existing ? existing.parentAssetId : editParentId,
originalAssetId: existing ? existing.originalAssetId : editOriginalId,
pixels: encodePixels(aligned.rightPixels),
faces: category === 'dynamic' ? {
right: encodePixels(aligned.rightPixels),
left: 'mirror'
} : null,
meta: buildAssetMeta(category, subtype, aligned.depthPixels, aligned.lightPixels, selectedColorCode, aligned.door, size)
};
asset.contentHash = computeAssetContentHash(asset);
if (existing) {
const index = state.assets.findIndex((a) => a.id === existing.id);
if (index >= 0) state.assets[index] = asset;
toast(`${asset.name} updated.`);
} else {
state.assets.unshift(asset);
toast(`${asset.name} saved.`);
}
selectedAssetId = asset.id;
recordSyncEvent(Phase2Sync?.createAssetUpsertEvent?.(asset));
editParentId = null;
editOriginalId = null;
editingAssetId = null;
editingAssetId = asset.id;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
els.lineageNote.textContent = existing ? 'Updated the original asset.' : 'Saved as a new asset.';
return asset;
}
function saveAndPlaceFromEditor() {
const asset = saveAssetFromEditor();
if (!asset) return;
selectedAssetId = asset.id;
setMode('place');
setDrawerOpen(false);
toast('Saved. Click an island tile to summon it.');
}
function newAsset() {
editParentId = null;
editOriginalId = null;
els.assetName.value = '';
els.assetCategory.value = 'nature';
staticKind = 'nature';
dynamicKind = 'human';
editingSide = 'right';
lightPixels = [];
depthPixels = blankPixels(8).map(() => 0);
doorPixel = { x: Math.floor(editorSize / 2), y: editorSize - 1 };
setupEditor(8, blankPixels(8), null);
clearEditorHistory();
refreshCategoryUI();
els.lineageNote.textContent = '';
}
function hydrateAuthorUI() {
state.authorName = state.authorName || 'Local Artist';
if (els.authorName) els.authorName.value = state.authorName;
}
function focusAssetInWorld(asset) {
const placed = state.placed.find((p) => p.assetId === asset.id);
const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id);
const target = placed ? { x: placed.x + .5, y: placed.y + .5 } : dyn ? { x: dyn.homeX + .5, y: dyn.homeY + .5 } : null;
if (!target) {
selectedObject = null;
setMode('place');
toast('Selected. Place it on the map.');
return;
}
const pos = tileToWorld(target.x, target.y);
pos.y -= getLiftAtCoord(target.x, target.y);
view.x = cw / 2 - pos.x * view.zoom;
view.y = ch / 2 - pos.y * view.zoom;
if (placed) selectWorldObject('static', placed.id, asset.id, performance.now());
else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now());
setMode('inspect');
}
function renderLibrary() {
els.assetList.innerHTML = '';
const visibleAssets = state.assets.filter((asset) => !state.hiddenAssets?.[asset.id]);
if (!visibleAssets.length) {
els.assetList.textContent = 'No visible assets.';
renderHiddenAssets();
return;
}
const myName = (state.authorName || 'Local Artist').trim() || 'Local Artist';
const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter;
const mine = visibleAssets.filter((asset) => (asset.author || 'Local Artist') === myName && matchesFilter(asset));
const others = visibleAssets.filter((asset) => (asset.author || 'Local Artist') !== myName && matchesFilter(asset));
addLibrarySection('My works', mine);
addLibrarySection('Others', others);
renderHiddenAssets();
}
function addLibrarySection(title, assets) {
const heading = document.createElement('button');
heading.type = 'button';
heading.className = 'librarySectionTitle';
heading.textContent = `${title} · ${libraryFilterLabel()}`;
heading.title = 'Click to filter by genre';
heading.addEventListener('click', () => {
cycleLibraryFilter();
renderLibrary();
});
els.assetList.append(heading);
if (!assets.length) {
const empty = document.createElement('div');
empty.className = 'libraryEmptyNote';
empty.textContent = 'No assets in this genre.';
els.assetList.append(empty);
return;
}
const grid = document.createElement('div');
grid.className = 'assetSectionGrid';
for (const asset of assets) grid.append(makeAssetCard(asset));
els.assetList.append(grid);
}
function libraryFilterLabel() {
return libraryFilter === 'all' ? 'All' : cap(libraryFilter);
}
function cycleLibraryFilter() {
const filters = ['all', 'human', 'animal', 'nature', 'building', 'ship', 'other'];
const index = filters.indexOf(libraryFilter);
libraryFilter = filters[(index + 1) % filters.length];
toast(`Library filter: ${libraryFilterLabel()}`);
}
function makeAssetCard(asset) {
const card = document.createElement('article');
card.className = `assetCard${asset.id === selectedAssetId ? ' selected' : ''}`;
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
preview.width = 64;
preview.height = 64;
drawPreview(preview, asset);
const meta = document.createElement('div');
meta.className = 'assetMeta';
const lineage = asset.parentAssetId ? ' / derivative' : '';
meta.innerHTML = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}`;
meta.querySelector('strong').textContent = asset.name;
meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
card.addEventListener('click', () => {
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
focusAssetInWorld(asset);
});
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
const up = makeButton(`▲ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
const down = makeButton(`▼ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
down.classList.toggle('mutedVote', assetPreviousVote > 0);
const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => {
event?.stopPropagation?.();
copyEdit(asset);
});
const move = makeButton('Place/Move', (event) => {
event?.stopPropagation?.();
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
setMode('place');
setDrawerOpen(false);
toast('Click a valid tile to place or move it.');
});
const del = makeButton('Delete', (event) => {
event?.stopPropagation?.();
deleteAsset(asset);
});
del.classList.add('danger');
actions.append(up, down, hide, move, copy, del);
meta.append(actions);
card.append(preview, meta);
return card;
}
function renderHiddenAssets() {
if (!els.hiddenAssetList) return;
els.hiddenAssetList.innerHTML = '';
const hiddenAssets = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]);
const hiddenObjects = collectHiddenObjects();
if (!hiddenAssets.length && !hiddenObjects.length) {
els.hiddenAssetList.textContent = 'No hidden assets or objects.';
return;
}
if (hiddenObjects.length) {
const title = document.createElement('div');
title.className = 'hiddenSectionTitle';
title.textContent = 'Hidden map objects';
els.hiddenAssetList.append(title);
for (const entry of hiddenObjects) els.hiddenAssetList.append(makeHiddenObjectCard(entry));
}
if (hiddenAssets.length) {
const title = document.createElement('div');
title.className = 'hiddenSectionTitle';
title.textContent = 'Hidden library assets';
els.hiddenAssetList.append(title);
for (const asset of hiddenAssets) els.hiddenAssetList.append(makeHiddenAssetCard(asset));
}
}
function collectHiddenObjects() {
const out = [];
const reportsByObject = new Map((state.moderationReports || []).map((report) => [report.objectId, report]));
for (const placed of state.placed || []) {
if (!state.hiddenObjects?.[placed.id]) continue;
const asset = findAsset(placed.assetId);
if (!asset) continue;
out.push({ kind: 'static', object: placed, asset, report: reportsByObject.get(placed.id) || null });
}
for (const summon of state.dynamicSummons || []) {
if (!state.hiddenObjects?.[summon.id]) continue;
const asset = findAsset(summon.assetId);
if (!asset) continue;
out.push({ kind: 'dynamic', object: summon, asset, report: reportsByObject.get(summon.id) || null });
}
return out;
}
function makeHiddenObjectCard(entry) {
const { kind, object, asset, report } = entry;
const row = document.createElement('article');
row.className = 'assetCard hiddenAssetCard';
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
preview.width = 64;
preview.height = 64;
drawPreview(preview, asset);
const meta = document.createElement('div');
meta.className = 'assetMeta';
const location = kind === 'dynamic'
? `home ${Math.round(object.homeX)},${Math.round(object.homeY)}`
: `tile ${object.x},${object.y}`;
meta.innerHTML = ``;
meta.querySelector('strong').textContent = asset.name;
meta.querySelectorAll('span')[0].textContent = `${cap(kind)} object / ${location}`;
meta.querySelectorAll('span')[1].textContent = report ? `Reported: ${report.reason}` : 'Hidden locally';
const actions = document.createElement('div');
actions.className = 'assetActions';
const restore = makeButton('Show again', (event) => {
event.stopPropagation();
delete state.hiddenObjects[object.id];
saveState();
hydrateRuntime();
renderLibrary();
updateSelectionBubble(performance.now());
toast('Object shown again.');
});
actions.append(restore);
meta.append(actions);
row.append(preview, meta);
return row;
}
function makeHiddenAssetCard(asset) {
const row = document.createElement('article');
row.className = 'assetCard hiddenAssetCard';
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
preview.width = 64;
preview.height = 64;
drawPreview(preview, asset);
const meta = document.createElement('div');
meta.className = 'assetMeta';
meta.innerHTML = `Author: ${asset.author || 'Local Artist'}`;
meta.querySelector('strong').textContent = asset.name;
const actions = document.createElement('div');
actions.className = 'assetActions';
const restore = makeButton('Show again', (event) => {
event.stopPropagation();
delete state.hiddenAssets[asset.id];
saveState();
renderLibrary();
updateSelectionBubble(performance.now());
toast('Asset shown again.');
});
actions.append(restore);
meta.append(actions);
row.append(preview, meta);
return row;
}
function drawPreview(canvas, asset) {
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
c.clearRect(0, 0, canvas.width, canvas.height);
c.fillStyle = '#fff3d9';
c.fillRect(0, 0, canvas.width, canvas.height);
const pixels = getAssetPixels(asset, 'right');
const scale = Math.floor(48 / asset.size) || 1;
const ox = Math.floor((canvas.width - asset.size * scale) / 2);
const oy = Math.floor((canvas.height - asset.size * scale) / 2);
for (let y = 0; y < asset.size; y++) {
for (let x = 0; x < asset.size; x++) {
const color = pixels[y * asset.size + x];
if (!color) continue;
c.fillStyle = colorToHex(color);
c.fillRect(ox + x * scale, oy + y * scale, scale, scale);
}
}
}
function renderPalette() {
if (!els.paletteGrid) return;
els.paletteGrid.innerHTML = '';
for (const entry of PALETTE) {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = `paletteSwatch${entry.code === selectedColorCode ? ' active' : ''}`;
swatch.dataset.code = entry.code;
swatch.title = `${entry.code} ${entry.color}`;
swatch.style.background = entry.color;
swatch.addEventListener('click', () => {
selectPaletteCode(entry.code);
drawEditor();
});
els.paletteGrid.append(swatch);
}
els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b';
}
function displayCategory(asset) {
return asset.category === 'dynamic' ? 'People & Animals' : (asset.subtype === 'ship' ? 'Ships' : 'Buildings & Nature');
}
function makeButton(label, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', onClick);
return button;
}
function copyEdit(asset) {
setDrawerOpen(true);
setTab('draw');
const mine = (asset.author || 'Local Artist') === (state.authorName || 'Local Artist');
editingAssetId = mine ? asset.id : null;
editParentId = mine ? asset.parentAssetId : asset.id;
editOriginalId = mine ? asset.originalAssetId : (asset.originalAssetId || asset.id);
els.assetName.value = mine ? asset.name : `${asset.name} Remix`;
els.assetCategory.value = subtypeToRole(asset);
staticKind = asset.category === 'static' ? asset.subtype : staticKind;
dynamicKind = asset.category === 'dynamic' ? asset.subtype : dynamicKind;
editingSide = 'right';
const right = getAssetPixels(asset, 'right');
lightPixels = (asset.meta?.lightPixels || []).map((p) => ({ x: p.x, y: p.y, c: p.c || asset.meta?.lightColor || selectedColorCode }));
depthPixels = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size);
doorPixel = asset.meta?.door || { x: Math.floor(asset.size / 2), y: asset.size - 1 };
setupEditor(asset.size, right, null, depthPixels);
clearEditorHistory();
refreshCategoryUI();
els.lineageNote.textContent = mine ? `Editing original “${asset.name}”.` : `Editing a derivative of “${asset.name}”. Save creates a new asset.`;
}
function deleteAsset(asset) {
const used = state.placed.some((p) => p.assetId === asset.id) || state.dynamicSummons.some((p) => p.assetId === asset.id);
if (used && !confirm(`Delete “${asset.name}”? It is used in the world and will be removed there too.`)) return;
state.assets = state.assets.filter((a) => a.id !== asset.id);
state.placed = state.placed.filter((p) => p.assetId !== asset.id);
state.dynamicSummons = state.dynamicSummons.filter((p) => p.assetId !== asset.id);
if (selectedAssetId === asset.id) selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
}
function updateSelectedLabel() {
if (!els.selectedAssetName) return;
const asset = findAsset(selectedAssetId);
els.selectedAssetName.textContent = asset ? `Selected: ${asset.name} (${displayCategory(asset)} / ${cap(asset.subtype)})` : 'Selected: none';
}
function hydrateRuntime() {
dynamicRuntime = state.dynamicSummons.map((summon) => {
const asset = findAsset(summon.assetId);
if (!asset) return null;
const homeTile = world.get(Math.round(summon.homeX), Math.round(summon.homeY));
const homeValid = homeTile && ((asset.subtype === 'fish') ? homeTile.type === 'water' : homeTile.type !== 'water');
const spawn = homeValid ? { x: Math.round(summon.homeX), y: Math.round(summon.homeY) } : findValidSpawn(asset, summon.homeX, summon.homeY, 6);
return {
id: summon.id,
assetId: summon.assetId,
homeX: summon.homeX,
homeY: summon.homeY,
x: spawn.x + .5,
y: spawn.y + .5,
targetX: spawn.x + .5,
targetY: spawn.y + .5,
vx: 0,
facing: 1,
idleUntil: performance.now() + 700 + Math.random() * 1600,
hiddenUntil: 0,
seed: Math.random() * 9999,
nextDecisionAt: 0,
nextBubbleAt: 800 + Math.random() * 1500,
nextStepParticleAt: performance.now() + 110 + Math.random() * 160
};
}).filter(Boolean);
}
function findValidSpawn(asset, homeX, homeY, radius) {
if (asset.subtype === 'bird') {
const x = clamp(Math.round(homeX), 0, WORLD_W - 1);
const y = clamp(Math.round(homeY), 0, WORLD_H - 1);
if (world.get(x, y)?.type !== 'water') return { x, y };
}
const candidates = [];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = Math.round(homeX + dx);
const y = Math.round(homeY + dy);
if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) continue;
const tile = world.get(x, y);
if (asset.subtype === 'fish') {
if (tile.type === 'water') candidates.push({ x, y });
} else if (tile.type !== 'water') candidates.push({ x, y });
}
}
if (!candidates.length) return { x: clamp(Math.round(homeX), 0, WORLD_W - 1), y: clamp(Math.round(homeY), 0, WORLD_H - 1) };
return candidates[Math.floor(Math.random() * candidates.length)];
}
function updateDynamicRuntime(dt, time) {
bubbleParticles = bubbleParticles.filter((p) => time - p.started < p.life);
confettiParticles = confettiParticles.filter((p) => time - p.started < p.life);
landStepParticles = landStepParticles.filter((p) => time - p.started < p.life);
natureDriftParticles = natureDriftParticles.filter((p) => time - p.started < p.life);
for (const item of dynamicRuntime) {
const asset = findAsset(item.assetId);
if (!asset) continue;
if (time < item.hiddenUntil) continue;
if (visualSettings().enableParticles && asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) {
spawnFishBubbleCluster(item.x, item.y, time, item.seed);
item.nextBubbleAt = time + 1100 + Math.random() * 1800;
}
if (time < (item.idleUntil || 0)) {
item.vx = 0;
continue;
}
const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y);
if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time);
if (time < (item.idleUntil || 0)) {
item.vx = 0;
continue;
}
const dx = item.targetX - item.x;
const dy = item.targetY - item.y;
const len = Math.hypot(dx, dy) || 1;
const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt;
const step = Math.min(speed, len);
if (Math.abs(dx) > 0.02) item.facing = Math.sign(dx);
item.vx = Math.abs(dx) > 0.02 ? Math.sign(dx) : 0;
item.x += (dx / len) * step;
item.y += (dy / len) * step;
if (visualSettings().enableParticles && asset.subtype !== 'fish' && asset.subtype !== 'bird' && time >= (item.nextStepParticleAt || 0)) {
const groundTile = world.get(clamp(Math.floor(item.x), 0, WORLD_W - 1), clamp(Math.floor(item.y), 0, WORLD_H - 1));
if (groundTile && groundTile.type !== 'water') {
spawnGroundStepParticles(item, time, groundTile);
}
item.nextStepParticleAt = time + 110 + Math.random() * 140;
}
if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) {
item.hiddenUntil = time + 1700 + Math.random() * 2200;
}
}
}
function chooseTarget(item, asset, time) {
const idleChance = ({ human: .34, animal: .42, fish: .24, bird: .16 }[asset.subtype] || .32);
if (Math.random() < idleChance) {
item.idleUntil = time + 900 + Math.random() * 2600;
item.targetX = item.x;
item.targetY = item.y;
item.vx = 0;
item.nextDecisionAt = item.idleUntil + 400 + Math.random() * 1200;
return;
}
let target = null;
if (asset.subtype === 'human') target = findNearestPlaced(item.x, item.y, 'building', 14);
else if (asset.subtype === 'animal' || asset.subtype === 'bird') target = findNearestPlaced(item.x, item.y, 'nature', 16);
if (target && Math.random() < .72) {
item.targetX = target.x + .5 + (Math.random() - .5) * 1.8;
item.targetY = target.y + .5 + (Math.random() - .5) * 1.8;
} else {
const spawn = findValidSpawn(asset, item.homeX, item.homeY, asset.subtype === 'bird' ? 2 : 7);
item.targetX = spawn.x + .5;
item.targetY = spawn.y + .5;
}
item.idleUntil = 0;
item.nextDecisionAt = time + 1800 + Math.random() * 3200;
}
function findNearestPlaced(x, y, subtype, maxDistance) {
let best = null;
let bestDistance = maxDistance;
const minX = clampInt(Math.floor(x - maxDistance), 0, WORLD_W - 1, 0);
const maxX = clampInt(Math.ceil(x + maxDistance), 0, WORLD_W - 1, WORLD_W - 1);
const minY = clampInt(Math.floor(y - maxDistance), 0, WORLD_H - 1, 0);
const maxY = clampInt(Math.ceil(y + maxDistance), 0, WORLD_H - 1, WORLD_H - 1);
for (let ty = minY; ty <= maxY; ty++) {
for (let tx = minX; tx <= maxX; tx++) {
const bucket = worldIndex.placedByTile.get(tileKey(tx, ty));
if (!bucket) continue;
for (const placed of bucket) {
const asset = findAsset(placed.assetId);
if (!asset || asset.subtype !== subtype) continue;
const distance = Math.hypot(placed.x + .5 - x, placed.y + .5 - y);
if (distance < bestDistance) {
bestDistance = distance;
best = placed;
}
}
}
}
return best;
}
function scheduleFrame() {
if (animationFrameId || document.hidden) return;
animationFrameId = requestAnimationFrame(tick);
}
function onVisibilityChange() {
if (document.hidden) {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationFrameId = 0;
lastRuntimeUpdate = performance.now();
dynamicLogicRemainder = 0;
return;
}
lastRuntimeUpdate = performance.now();
dynamicLogicRemainder = 0;
render(lastRuntimeUpdate);
scheduleFrame();
}
function tick(time) {
animationFrameId = 0;
if (document.hidden) {
lastRuntimeUpdate = time;
return;
}
const elapsed = Math.min(100, Math.max(0, time - lastRuntimeUpdate));
lastRuntimeUpdate = time;
dynamicLogicRemainder += elapsed;
let steps = 0;
while (dynamicLogicRemainder >= DYNAMIC_LOGIC_STEP_MS && steps < MAX_DYNAMIC_STEPS_PER_FRAME) {
updateDynamicRuntime(DYNAMIC_LOGIC_STEP_MS / 1000, time);
dynamicLogicRemainder -= DYNAMIC_LOGIC_STEP_MS;
steps++;
}
if (steps >= MAX_DYNAMIC_STEPS_PER_FRAME) dynamicLogicRemainder = 0;
updateClock();
render(time);
scheduleFrame();
}
function updateClock() {
const phase = getPhase();
const second = Math.floor(Date.now() / 1000);
if (second === lastClockSecond) return;
lastClockSecond = second;
els.phaseLabel.textContent = phase.label;
els.phaseBar.style.width = `${Math.round(phase.progress * 100)}%`;
}
function getShadowForMinute(minute) {
const isNight = minute >= 6;
const local = isNight ? (minute - 6) / 4 : minute / 6;
const eased = clamp(local, 0, 1);
const elevation = Math.sin(eased * Math.PI);
// Morning: down-left, Noon: straight down, Evening: down-right.
// The top edge of the shadow is always anchored to the sprite's bottom edge.
const dirX = lerp(-1.0, 1.0, eased);
const length = lerp(1.35, 0.58, elevation);
return {
dirX,
length,
skewX: dirX * (0.72 + 0.32 * length),
scaleY: 0.24 + 0.06 * length,
alpha: isNight ? .12 : lerp(.28, .16, elevation)
};
}
function getPhase() {
if (visualSettings().enableDayNight === false) {
return { key: 'day', label: 'Day', progress: 0.25, sky: '#86d5ff', darkness: 0, tint: 'rgba(255,255,255,0)', tintAlpha: 0, shadow: getShadowForMinute(3) };
}
const t = mod(Date.now(), DAY_MS);
const minute = t / 60000;
const stops = [
// Fast dawn: sky shifts quickly, then stays stable through the day.
{ at: 0.00, key: 'morning', label: 'Morning', sky: '#f6b06f', darkness: 0.22, tint: [255, 204, 132, 0.16] },
{ at: 0.55, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] },
{ at: 5.00, key: 'day', label: 'Day', sky: '#86d5ff', darkness: 0.00, tint: [255, 255, 255, 0.00] },
// Fast dusk: the main sky movement happens here.
{ at: 5.55, key: 'evening', label: 'Evening', sky: '#f09076', darkness: 0.22, tint: [255, 128, 96, 0.15] },
{ at: 6.10, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] },
{ at: 10.00, key: 'night', label: 'Night', sky: '#182a58', darkness: 0.58, tint: [14, 22, 54, 0.10] }
];
let a = stops[0], b = stops[1];
for (let i = 0; i < stops.length - 1; i++) {
if (minute >= stops[i].at && minute < stops[i + 1].at) { a = stops[i]; b = stops[i + 1]; break; }
}
const localT = clamp((minute - a.at) / Math.max(0.0001, b.at - a.at), 0, 1);
const eased = localT * localT * (3 - 2 * localT);
const tint = a.tint.map((v, i) => lerp(v, b.tint[i], eased));
const label = minute < 0.55 ? 'Morning' : minute < 5 ? 'Day' : minute < 6.10 ? 'Evening' : 'Night';
return {
key: label.toLowerCase(),
label,
progress: t / DAY_MS,
sky: mixHex(a.sky, b.sky, eased),
darkness: lerp(a.darkness, b.darkness, eased),
tint: `rgba(${Math.round(tint[0])}, ${Math.round(tint[1])}, ${Math.round(tint[2])}, ${tint[3].toFixed(3)})`,
tintAlpha: tint[3],
shadow: getShadowForMinute(minute)
};
}
function render(time = performance.now()) {
const phase = getPhase();
renderPhase = phase;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = phase.sky || '#86d5ff';
ctx.fillRect(0, 0, cw, ch);
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
ctx.imageSmoothingEnabled = false;
drawTerrainCache(terrainCache);
if (visualSettings().enableParticles) drawCoastalFoam(time);
drawHoverTile();
const lightSources = [];
const visibleItems = drawObjects(time, lightSources, phase);
if (visualSettings().enableParticles) {
spawnNatureAmbientParticles(visibleItems, time);
drawNatureDriftParticles(time);
drawLandStepParticles(time);
drawBubbleParticles(time);
drawSpawnEffects(time);
drawConfettiParticles(time);
}
if (visualSettings().enableLights) drawSurfaceLightBleed(visibleItems, lightSources, time);
ctx.restore();
updateSelectionBubble(time);
if ((phase.tintAlpha || 0) > 0.001) {
ctx.fillStyle = phase.tint;
ctx.fillRect(0, 0, cw, ch);
}
if (phase.darkness > 0) {
ctx.fillStyle = `rgba(12, 19, 45, ${phase.darkness})`;
ctx.fillRect(0, 0, cw, ch);
}
if (visualSettings().enableLights && phase.darkness > 0) drawLightSources(lightSources, phase.darkness);
}
function drawHoverTile() {
if (!hoverTile) return;
const { x, y } = tileToWorld(hoverTile.x, hoverTile.y);
const lift = getTileLift(hoverTile.tile);
ctx.beginPath();
ctx.moveTo(x, y - lift);
ctx.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
ctx.lineTo(x, y + TILE_H - lift);
ctx.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
ctx.closePath();
ctx.strokeStyle = 'rgba(255, 255, 255, .7)';
ctx.lineWidth = 1 / view.zoom;
ctx.stroke();
}
function drawSpawnEffects(time) {
if (!spawnEffects.length) return;
spawnEffects = spawnEffects.filter((effect) => time - effect.started < 650);
for (const effect of spawnEffects) {
const t = clamp((time - effect.started) / 650, 0, 1);
const pos = tileToWorld(effect.x, effect.y);
pos.y -= getLiftAtCoord(effect.x, effect.y);
const radius = 6 + t * 24;
ctx.save();
ctx.globalAlpha = 1 - t;
ctx.lineWidth = Math.max(1, 3 / view.zoom);
ctx.strokeStyle = '#ff79ad';
ctx.beginPath();
ctx.arc(pos.x, pos.y + TILE_H * .35, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#fff2a8';
for (let i = 0; i < 5; i++) {
const a = i * Math.PI * .4 + t * 2.4;
ctx.fillRect(pos.x + Math.cos(a) * radius - 2, pos.y + TILE_H * .35 + Math.sin(a) * radius - 2, 4, 4);
}
ctx.restore();
}
}
function drawObjects(time, lightSources, phase) {
const items = getDrawableItems(time, getViewportWorldRect(VIEW_CULL_MARGIN));
for (const item of items) {
if (item.asset.category === 'dynamic' && item.asset.subtype === 'fish') {
drawSpriteItem(item, time, lightSources, true, phase);
}
}
for (const item of items) {
if (!(item.asset.category === 'dynamic' && item.asset.subtype === 'fish')) {
drawSpriteItem(item, time, lightSources, false, phase);
}
}
return items;
}
function getSpriteDrawInfo(item, time, includeSelectBounce = true) {
const asset = item.asset;
const pos = tileToWorld(item.x, item.y);
const applyTileLift = !(asset.category === 'dynamic' && asset.subtype === 'bird');
if (applyTileLift) pos.y -= getLiftAtCoord(item.x, item.y);
let bob = 0;
if (includeSelectBounce && asset.category === 'static' && selectedObject?.id === item.source?.id) {
const selectedT = clamp((time - (selectedObject.selectedAt || 0)) / 520, 0, 1);
if (selectedT < 1) bob += -Math.sin(selectedT * Math.PI) * 8;
}
let alpha = 1;
let side = 'right';
let angle = 0;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) bob += Math.sin(time / 900 + item.x * .7) * 1.6;
if (asset.category === 'dynamic') {
const runtime = item.source;
side = (runtime.facing || runtime.vx || 1) < 0 ? 'left' : 'right';
if (asset.subtype === 'human' || asset.subtype === 'animal') {
const idle = time < (runtime.idleUntil || 0);
if (!idle) {
const jumpPhase = time / 78 + runtime.seed * 0.9;
const hop = Math.pow(Math.abs(Math.sin(jumpPhase)), 1.15);
const liftNow = getLiftAtCoord(item.x, item.y);
const liftTarget = getLiftAtCoord(runtime.targetX, runtime.targetY);
const terrainBoost = Math.abs(liftTarget - liftNow) / 12;
bob = -hop * (2.2 + terrainBoost * 1.8 + (liftNow > 0 ? 0.6 : 0));
const cycle = Math.floor(jumpPhase / (Math.PI * 0.9));
const rand = pseudoNoise(cycle + runtime.seed * 13.17);
angle = (rand - 0.5) * 0.24 * hop;
}
}
if (asset.subtype === 'bird') bob = -10 - Math.sin(time / 250 + runtime.seed) * 4.5;
if (asset.subtype === 'fish') { alpha = .42; bob = 4 + Math.sin(time / 380 + runtime.seed) * 1.7; }
}
const sprite = getSpriteCanvas(asset, side);
const drawX = pos.x - sprite.width / 2;
const anchorY = asset.category === 'static' ? 0 : TILE_H / 2;
const drawY = pos.y + anchorY - sprite.height + bob;
return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob, anchorY };
}
function drawSpriteItem(item, time, lightSources, underwater, phase) {
const { asset, pos, sprite, drawX, drawY, alpha, angle, side } = getSpriteDrawInfo(item, time, true);
if (visualSettings().enableParticles && asset.category === 'static' && asset.subtype === 'ship') drawShipRipples(pos, time, item.source?.id || asset.id);
if (!(asset.category === 'dynamic' && asset.subtype === 'fish')) drawSpriteShadow(drawX, drawY, sprite, phase);
ctx.save();
ctx.globalAlpha = alpha;
if (angle) {
ctx.translate(Math.round(drawX + sprite.width / 2), Math.round(drawY + sprite.height * 0.8));
ctx.rotate(angle);
ctx.drawImage(sprite, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8));
} else {
ctx.drawImage(sprite, Math.round(drawX), Math.round(drawY));
}
if (underwater) {
ctx.fillStyle = 'rgba(103, 181, 217, .18)';
if (angle) ctx.fillRect(Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8), sprite.width, sprite.height);
else ctx.fillRect(Math.round(drawX), Math.round(drawY), sprite.width, sprite.height);
}
ctx.restore();
if (asset.meta?.hasLight && asset.meta.lightPixels?.length) {
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
for (const light of asset.meta.lightPixels) {
const lx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - light.x : light.x;
const wx = drawX + (lx + 0.5) * scale;
const wy = drawY + (light.y + 0.5) * scale;
lightSources.push({ x: wx, y: wy, color: colorToHex(light.c || asset.meta.lightColor || nearestPaletteCode('#ffd86a')), radius: lerp(10, 16, asset.size / 64), ownerId: item.source?.id || asset.id });
}
}
}
function drawSurfaceLightBleed(items, lightSources, time) {
if (!items?.length || !lightSources?.length) return;
for (const item of items) {
const info = getSpriteDrawInfo(item, time, false);
const sprite = info.sprite;
const localSources = [];
for (const source of lightSources) {
if (source.ownerId === item.source?.id) continue;
const dx = source.x - (info.drawX + sprite.width / 2);
const dy = source.y - (info.drawY + sprite.height / 2);
const reach = source.radius * 2.2 + Math.max(sprite.width, sprite.height) * 0.55;
const distance = Math.hypot(dx, dy);
if (distance > reach) continue;
localSources.push({ source, distance });
}
if (!localSources.length) continue;
localSources.sort((a, b) => a.distance - b.distance);
const overlay = document.createElement('canvas');
overlay.width = sprite.width;
overlay.height = sprite.height;
const octx = overlay.getContext('2d');
octx.imageSmoothingEnabled = false;
octx.drawImage(sprite, 0, 0);
octx.globalCompositeOperation = 'source-atop';
for (const { source, distance } of localSources.slice(0, 2)) {
const localX = source.x - info.drawX;
const localY = source.y - info.drawY;
const radius = Math.max(12, source.radius * 1.7);
const influence = 1 - clamp(distance / (source.radius * 2.4 + 36), 0, 1);
if (influence <= 0.02) continue;
const grad = octx.createRadialGradient(localX, localY, 0, localX, localY, radius);
grad.addColorStop(0, hexToRgba(source.color, 0.22 * influence));
grad.addColorStop(0.5, hexToRgba(source.color, 0.10 * influence));
grad.addColorStop(1, 'rgba(255,255,255,0)');
octx.fillStyle = grad;
octx.fillRect(0, 0, overlay.width, overlay.height);
}
ctx.save();
ctx.globalAlpha = 0.9;
if (info.angle) {
ctx.translate(Math.round(info.drawX + sprite.width / 2), Math.round(info.drawY + sprite.height * 0.8));
ctx.rotate(info.angle);
ctx.drawImage(overlay, Math.round(-sprite.width / 2), Math.round(-sprite.height * 0.8));
} else {
ctx.drawImage(overlay, Math.round(info.drawX), Math.round(info.drawY));
}
ctx.restore();
}
}
function spawnGroundStepParticles(item, time, tile) {
const baseColor = getTerrainSurfaceColorAt(item.x, item.y);
const count = 1 + (Math.random() < 0.5 ? 1 : 0);
for (let i = 0; i < count; i++) {
landStepParticles.push({
x: item.x + (Math.random() - 0.5) * 0.18,
y: item.y + 0.16 + (Math.random() - 0.5) * 0.05,
vx: (Math.random() - 0.5) * 0.045 - (item.vx || 0) * 0.012,
vy: -0.015 - Math.random() * 0.02,
started: time + i * 18,
life: 260 + Math.random() * 180,
color: varyHexColor(baseColor, 26, 16, 12),
tileType: tile?.type || 'grass'
});
}
}
function drawLandStepParticles(time) {
if (!landStepParticles.length) return;
ctx.save();
for (const particle of landStepParticles) {
const age = (time - particle.started) / particle.life;
if (age < 0 || age > 1) continue;
const px = particle.x + particle.vx * age * 18;
const py = particle.y + particle.vy * age * 18;
const pos = tileToWorld(px, py);
pos.y -= getLiftAtCoord(px, py);
ctx.globalAlpha = (1 - age) * 0.7;
ctx.fillStyle = particle.color;
const size = age < 0.28 ? 2 : 1;
ctx.fillRect(Math.round(pos.x), Math.round(pos.y + TILE_H * 0.22 - age * 3), size, size);
}
ctx.restore();
}
function spawnNatureAmbientParticles(items, time) {
if (natureDriftParticles.length > 120) return;
const natureItems = items.filter((item) => item.asset.category === 'static' && item.asset.subtype === 'nature');
for (const item of natureItems) {
if (Math.random() > 0.025) continue;
const info = getSpriteDrawInfo(item, time, false);
natureDriftParticles.push({
x: item.x + (Math.random() - 0.5) * 0.55,
y: item.y - 0.15 - Math.random() * 0.7,
vx: -0.012 + Math.random() * 0.024,
vy: 0.018 + Math.random() * 0.024,
started: time,
life: 1800 + Math.random() * 1800,
color: Math.random() < 0.55 ? ['#7abf64', '#99d57b', '#f4a1bb', '#ffd56e'][Math.floor(Math.random() * 4)] : ['#f7c3d7', '#ffef8b', '#8bd68f'][Math.floor(Math.random() * 3)],
sway: Math.random() * Math.PI * 2,
originDrawY: info.drawY
});
}
}
function drawNatureDriftParticles(time) {
if (!natureDriftParticles.length) return;
ctx.save();
for (const particle of natureDriftParticles) {
const age = (time - particle.started) / particle.life;
if (age < 0 || age > 1) continue;
const sway = Math.sin(age * 5.5 + particle.sway) * 0.08;
const px = particle.x + particle.vx * age * 22 + sway;
const py = particle.y + particle.vy * age * 22;
const pos = tileToWorld(px, py);
pos.y -= getLiftAtCoord(px, py);
ctx.globalAlpha = (1 - age) * 0.72;
ctx.fillStyle = particle.color;
ctx.fillRect(Math.round(pos.x), Math.round(pos.y - age * 5), 1, 1);
}
ctx.restore();
}
function drawShipRipples(pos, time, seedValue = '') {
const seed = fnv1a(String(seedValue)).slice(0, 6);
const numericSeed = parseInt(seed, 16) || 1;
const cycleMs = 2300;
const cycle = Math.floor((time + numericSeed) / cycleMs);
if (pseudoNoise(cycle + numericSeed * 0.013) < 0.38) return;
const t = ((time + numericSeed) % cycleMs) / cycleMs;
const alpha = Math.max(0, 0.36 * (1 - t));
const rx = 5 + t * 22;
const ry = 2 + t * 8;
ctx.save();
ctx.globalAlpha = alpha;
ctx.strokeStyle = '#e8fbff';
ctx.lineWidth = Math.max(1, 1.5 / view.zoom);
ctx.beginPath();
ctx.ellipse(Math.round(pos.x), Math.round(pos.y + TILE_H * .15), rx, ry, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
function drawFishBubbles(x, y, time, seed) {
const cycle = Math.floor((time + seed * 37) / 1300);
if (pseudoNoise(cycle + seed) < 0.55) return;
const t = ((time + seed * 37) % 1300) / 1300;
ctx.save();
ctx.fillStyle = 'rgba(225, 252, 255, .58)';
ctx.globalAlpha = Math.max(0, 1 - t);
for (let i = 0; i < 4; i++) {
const ox = Math.round((pseudoNoise(cycle + seed + i * 4.1) - .5) * 7);
const oy = Math.round(-t * 12 - i * 3);
ctx.fillRect(Math.round(x + ox), Math.round(y + oy), 1, 1);
}
ctx.restore();
}
function drawSpriteShadow(drawX, drawY, sprite, phase) {
const shadow = phase?.shadow || { dirX: 0, length: 1, skewX: 0, scaleY: 0.28, alpha: .16 };
const silhouette = getShadowCanvas(sprite);
const contactX = drawX + sprite.width / 2;
const contactY = drawY + sprite.height;
ctx.save();
ctx.globalAlpha = shadow.alpha;
// The silhouette is vertically flipped in getShadowCanvas().
// Drawing it at y=0 means the flipped top edge, which was the sprite's bottom edge,
// exactly touches the sprite's bottom/contact line.
ctx.translate(contactX, contactY);
ctx.transform(1 + shadow.length * 0.08, 0, shadow.skewX, shadow.scaleY, 0, 0);
ctx.drawImage(silhouette, Math.round(-sprite.width / 2), 0);
ctx.restore();
}
function getShadowCanvas(sprite) {
if (shadowCanvasCache.has(sprite)) return shadowCanvasCache.get(sprite);
const canvas = document.createElement('canvas');
canvas.width = sprite.width;
canvas.height = sprite.height;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
c.save();
c.translate(0, sprite.height);
c.scale(1, -1);
c.drawImage(sprite, 0, 0);
c.restore();
c.globalCompositeOperation = 'source-in';
c.fillStyle = '#1b1f26';
c.fillRect(0, 0, canvas.width, canvas.height);
shadowCanvasCache.set(sprite, canvas);
return canvas;
}
function currentVoterKey() {
return ((state.authorName || els.authorName?.value || 'Local Artist').trim() || 'Local Artist').toLowerCase();
}
function getObjectVoteCounts(objectId) {
state.objectVotes ||= {};
const votes = state.objectVotes[objectId] || { up: 0, down: 0, voters: {} };
votes.voters ||= {};
return votes;
}
function getAssetVoteCounts(assetId) {
state.assetVotes ||= {};
const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} };
votes.voters ||= {};
return votes;
}
function voteAsset(assetId, delta) {
state.assetVotes ||= {};
const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} };
votes.voters ||= {};
const voter = currentVoterKey();
const previous = votes.voters[voter] || 0;
if (previous > 0) votes.up = Math.max(0, votes.up - 1);
if (previous < 0) votes.down = Math.max(0, votes.down - 1);
if (previous === delta) delete votes.voters[voter];
else {
if (delta > 0) votes.up += 1;
else votes.down += 1;
votes.voters[voter] = delta;
}
state.assetVotes[assetId] = votes;
saveState();
renderLibrary();
}
function hideAsset(assetId) {
state.hiddenAssets ||= {};
state.hiddenAssets[assetId] = true;
if (selectedAssetId === assetId) selectedAssetId = state.assets.find((a) => !state.hiddenAssets?.[a.id])?.id || null;
if (selectedObject?.assetId === assetId) selectedObject = null;
saveState();
renderLibrary();
updateSelectionBubble(performance.now());
toast('Asset hidden locally.');
}
function getRemixCount(assetId) {
return state.assets.filter((asset) => asset.parentAssetId === assetId || asset.originalAssetId === assetId).length;
}
function countPixels(pixels) {
return Array.isArray(pixels) ? pixels.filter(Boolean).length : normalizePixels(pixels || [], editorSize).filter(Boolean).length;
}
function getSelectedWorldPosition() {
if (!selectedObject) return null;
const asset = findAsset(selectedObject.assetId);
if (!asset) return null;
if (selectedObject.kind === 'static') {
const placed = state.placed.find((p) => p.id === selectedObject.id);
if (!placed) return null;
const pos = tileToWorld(placed.x + .5, placed.y + .5);
pos.y -= getLiftAtCoord(placed.x + .5, placed.y + .5);
return { pos, asset, objectId: placed.id };
}
const runtime = dynamicRuntime.find((r) => r.id === selectedObject.id);
const summon = state.dynamicSummons.find((s) => s.id === selectedObject.id);
const source = runtime || (summon ? { x: summon.homeX + .5, y: summon.homeY + .5 } : null);
if (!source) return null;
const pos = tileToWorld(source.x, source.y);
if (asset.subtype !== 'bird') pos.y -= getLiftAtCoord(source.x, source.y);
return { pos, asset, objectId: selectedObject.id };
}
function updateSelectionBubble(time) {
if (!els.selectionBubble) return;
const selected = getSelectedWorldPosition();
if (!selected) {
els.selectionBubble.hidden = true;
return;
}
const { pos, asset, objectId } = selected;
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
const topOffset = Math.max(18, asset.size * scale + 26);
const sx = pos.x * view.zoom + view.x;
const sy = (pos.y - topOffset) * view.zoom + view.y;
if (sx < -100 || sy < -100 || sx > cw + 100 || sy > ch + 100) {
els.selectionBubble.hidden = true;
return;
}
els.selectionBubble.hidden = false;
els.selectionBubble.style.transform = `translate(${Math.round(sx)}px, ${Math.round(sy)}px) translate(-50%, -100%)`;
els.bubbleName.textContent = asset.name || 'Untitled';
els.bubbleAuthor.textContent = `by ${asset.author || 'Local Artist'}`;
const parent = asset.parentAssetId ? findAsset(asset.parentAssetId) : null;
if (els.bubbleRemixFrom) {
els.bubbleRemixFrom.hidden = !parent;
els.bubbleRemixFrom.textContent = parent ? `Remix of: ${parent.name || 'Untitled'}` : '';
}
if (els.bubbleRemixCount) els.bubbleRemixCount.textContent = `Remixed: ${getRemixCount(asset.id)}`;
const votes = getObjectVoteCounts(objectId);
const voter = currentVoterKey();
const previous = votes.voters?.[voter] || 0;
els.voteScore.textContent = `${votes.up - votes.down}`;
els.voteUp.classList.toggle('activeVote', previous > 0);
els.voteDown.classList.toggle('activeVote', previous < 0);
els.voteUp.classList.toggle('mutedVote', previous < 0);
els.voteDown.classList.toggle('mutedVote', previous > 0);
if (els.bubbleHide) els.bubbleHide.hidden = previous >= 0;
if (els.bubbleReport) {
const reported = hasReportForObject(objectId);
els.bubbleReport.disabled = reported;
els.bubbleReport.textContent = reported ? 'Reported' : 'Report';
}
}
function voteSelected(delta) {
if (!selectedObject) return;
state.objectVotes ||= {};
const key = selectedObject.id;
const votes = state.objectVotes[key] || { up: 0, down: 0, voters: {} };
votes.voters ||= {};
const voter = currentVoterKey();
const previous = votes.voters[voter] || 0;
if (previous > 0) votes.up = Math.max(0, votes.up - 1);
if (previous < 0) votes.down = Math.max(0, votes.down - 1);
if (previous === delta) {
delete votes.voters[voter];
} else {
if (delta > 0) {
votes.up += 1;
spawnConfettiAtSelection(performance.now());
} else {
votes.down += 1;
}
votes.voters[voter] = delta;
}
state.objectVotes[key] = votes;
saveState();
updateSelectionBubble(performance.now());
renderLibrary();
// Server moderation TODO: when online, hide or delete objects from the server if downvotes exceed a threshold
// such as down >= 5 and down - up >= 3. Do not implement server deletion in this local prototype.
}
function remixSelected() {
if (!selectedObject) return;
const asset = findAsset(selectedObject.assetId);
if (asset) copyEdit(asset);
}
function hideSelected() {
if (!selectedObject) return;
state.hiddenObjects ||= {};
state.hiddenObjects[selectedObject.id] = true;
const assetId = selectedObject.assetId;
selectedObject = null;
saveState();
updateSelectionBubble(performance.now());
renderLibrary();
toast('Hidden locally.');
// Server moderation TODO: when synced, repeated low-rating + hide actions can flag this object for review.
}
function hasReportForObject(objectId) {
const reporter = currentVoterKey();
return Array.isArray(state.moderationReports)
&& state.moderationReports.some((report) => report.objectId === objectId && report.reporter === reporter);
}
function openReportDialog() {
if (!selectedObject) return;
if (hasReportForObject(selectedObject.id)) {
toast('Already reported locally.');
updateSelectionBubble(performance.now());
return;
}
const asset = findAsset(selectedObject.assetId);
pendingReport = { ...selectedObject };
if (els.reportObjectName) els.reportObjectName.textContent = asset ? asset.name : 'selected object';
if (els.reportReason) els.reportReason.value = 'inappropriate';
if (els.reportDialog) {
els.reportDialog.hidden = false;
els.reportReason?.focus?.();
} else {
submitReport('local-report');
}
}
function closeReportDialog() {
pendingReport = null;
if (els.reportDialog) els.reportDialog.hidden = true;
}
function submitReportDialog() {
const reason = els.reportReason?.value || 'local-report';
submitReport(reason);
}
function submitReport(reason = 'local-report') {
const target = pendingReport || selectedObject;
if (!target) return;
const asset = findAsset(target.assetId);
state.moderationReports = normalizeModerationReports(state.moderationReports || []);
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
if (hasReportForObject(target.id)) {
closeReportDialog();
toast('Already reported locally.');
updateSelectionBubble(performance.now());
return;
}
const report = {
id: uid(),
objectId: target.id,
assetId: target.assetId,
objectKind: target.kind,
assetName: asset?.name || 'Untitled',
assetAuthor: asset?.author || 'Local Artist',
reporter: currentVoterKey(),
reason,
createdAt: Date.now()
};
state.moderationReports.push(report);
state.moderationReports = state.moderationReports.slice(-PHASE5_GUARDRAILS.maxReports);
state.hiddenObjects ||= {};
state.hiddenObjects[target.id] = true;
if (selectedObject?.id === target.id) selectedObject = null;
closeReportDialog();
saveState();
hydrateRuntime();
updateSelectionBubble(performance.now());
renderLibrary();
toast('Reported and hidden locally.');
}
function spawnFishBubbleCluster(tileX, tileY, time, seed = 0) {
const count = 2 + Math.floor(pseudoNoise(time * 0.003 + seed) * 3);
for (let i = 0; i < count; i++) {
bubbleParticles.push({
x: tileX + (pseudoNoise(seed + time * .01 + i * 7.1) - .5) * .22,
y: tileY + (pseudoNoise(seed + time * .013 + i * 5.7) - .5) * .22,
started: time + i * 45,
life: 900 + pseudoNoise(seed + i * 3.3) * 550,
drift: (pseudoNoise(seed + i * 9.9) - .5) * .08
});
}
}
function drawBubbleParticles(time) {
if (!bubbleParticles.length) return;
ctx.save();
ctx.fillStyle = 'rgba(225, 252, 255, .62)';
for (const particle of bubbleParticles) {
const age = (time - particle.started) / particle.life;
if (age < 0 || age > 1) continue;
const pos = tileToWorld(particle.x + particle.drift * age, particle.y);
pos.y -= 3 + age * 18;
ctx.globalAlpha = 1 - age;
ctx.fillRect(Math.round(pos.x), Math.round(pos.y), 1, 1);
}
ctx.restore();
}
function spawnConfettiAtSelection(time) {
const selected = getSelectedWorldPosition();
if (!selected) return;
const palette = ['a', 'f', 'l', 's', 'A', 'H', 'P', 'Z'];
for (let i = 0; i < 22; i++) {
const angle = (Math.PI * 2 * i) / 22 + pseudoNoise(time + i) * .4;
const speed = 18 + pseudoNoise(i * 4.7 + time) * 34;
confettiParticles.push({
x: selected.pos.x,
y: selected.pos.y - selected.asset.size * (selected.asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE) - 8,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 25,
color: palette[i % palette.length],
started: time,
life: 850 + pseudoNoise(i + time) * 450
});
}
}
function drawConfettiParticles(time) {
if (!confettiParticles.length) return;
ctx.save();
for (const particle of confettiParticles) {
const ageMs = time - particle.started;
const t = ageMs / particle.life;
if (t < 0 || t > 1) continue;
const seconds = ageMs / 1000;
const x = particle.x + particle.vx * seconds;
const y = particle.y + particle.vy * seconds + 48 * seconds * seconds;
ctx.globalAlpha = 1 - t;
ctx.fillStyle = colorToHex(particle.color);
ctx.fillRect(Math.round(x), Math.round(y), 3, 3);
}
ctx.restore();
}
function drawLightSources(sources, darkness) {
ctx.save();
for (const source of sources) {
const sx = source.x * view.zoom + view.x;
const sy = source.y * view.zoom + view.y;
const radius = source.radius * view.zoom;
const auraRadius = radius * 1.28;
if (sx < -auraRadius || sy < -auraRadius || sx > cw + auraRadius || sy > ch + auraRadius) continue;
// Light cores are drawn after the night overlay and do not inherit night darkness.
ctx.globalCompositeOperation = 'source-over';
const core = ctx.createRadialGradient(sx, sy, 0, sx, sy, Math.max(3, radius * 0.48));
core.addColorStop(0, hexToRgba(source.color, .84));
core.addColorStop(.34, hexToRgba(source.color, .44));
core.addColorStop(1, hexToRgba(source.color, .00));
ctx.fillStyle = core;
ctx.beginPath();
ctx.arc(sx, sy, Math.max(3, radius * 0.48), 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'lighter';
const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, auraRadius);
glow.addColorStop(0, hexToRgba(source.color, .22));
glow.addColorStop(.42, hexToRgba(source.color, .10));
glow.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(sx, sy, auraRadius, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function getSpriteCanvas(asset, side) {
const phaseKey = renderPhase ? `${renderPhase.key}:${Math.round((renderPhase.darkness || 0) * 10)}` : 'day';
const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}:${phaseKey}`;
if (spriteCache.has(key)) return spriteCache.get(key);
const pixels = getAssetPixels(asset, side);
const scale = asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE;
const canvas = document.createElement('canvas');
canvas.width = asset.size * scale;
canvas.height = asset.size * scale;
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
for (let y = 0; y < asset.size; y++) {
for (let x = 0; x < asset.size; x++) {
const color = pixels[y * asset.size + x];
if (!color) continue;
c.fillStyle = shadeAssetPixelColor(colorToHex(color), getAssetDepth(asset, x, y, side), x, y, asset.size, renderPhase);
c.fillRect(x * scale, y * scale, scale, scale);
}
}
spriteCache.set(key, canvas);
return canvas;
}
function getAssetPixels(asset, side = 'right') {
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
if (asset.category === 'dynamic' && side === 'left') return mirrorPixels(right, asset.size);
return right;
}
function makeWorld() {
const islands = [
{ cx: 36, cy: 34, rx: 32, ry: 22, height: 1.04 },
{ cx: 92, cy: 30, rx: 34, ry: 20, height: .98 },
{ cx: 74, cy: 76, rx: 42, ry: 24, height: 1.02 },
{ cx: 124, cy: 72, rx: 16, ry: 14, height: .92 },
{ cx: 26, cy: 86, rx: 16, ry: 12, height: .9 },
{ cx: 118, cy: 102, rx: 20, ry: 9, height: .86 }
];
const tiles = [];
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
const continent = fractalPerlin(x * .035, y * .035, 4);
const detail = fractalPerlin(x * .095 + 41.7, y * .095 - 13.2, 3);
let score = -1.2;
for (const island of islands) {
const warpX = (fractalPerlin(x * .055 + island.cx, y * .055, 2) - .5) * 7.0;
const warpY = (fractalPerlin(x * .055, y * .055 + island.cy, 2) - .5) * 5.0;
const dx = (x + warpX - island.cx) / island.rx;
const dy = (y + warpY - island.cy) / island.ry;
const base = 1 - (dx * dx + dy * dy);
score = Math.max(score, base * island.height);
}
score += (continent - .5) * .36 + (detail - .5) * .18;
let type = 'water';
if (score > .015) type = 'sand';
if (score > .17) type = 'grass';
if (score > .56 && fractalPerlin(x * .16 + 8, y * .16 + 4, 2) > .56) type = 'highland';
const heightLevel = type === 'highland' ? 1 : 0;
tiles.push({ x, y, type, heightLevel, shade: fractalPerlin(x * .31 + 9, y * .31 - 2, 2) - .5 });
}
}
return {
tiles,
get(x, y) {
if (x < 0 || y < 0 || x >= WORLD_W || y >= WORLD_H) return null;
return tiles[y * WORLD_W + x];
}
};
}
function buildTerrainCache(worldData) {
const chunkSize = TERRAIN_CHUNK_SIZE;
const chunks = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let cy = 0; cy < WORLD_H; cy += chunkSize) {
for (let cx = 0; cx < WORLD_W; cx += chunkSize) {
const tiles = [];
for (let y = cy; y < Math.min(WORLD_H, cy + chunkSize); y++) {
for (let x = cx; x < Math.min(WORLD_W, cx + chunkSize); x++) {
const tile = worldData.get(x, y);
if (tile) tiles.push(tile);
}
}
if (!tiles.length) continue;
const bounds = getTerrainChunkBounds(tiles);
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(bounds.w);
canvas.height = Math.ceil(bounds.h);
const c = canvas.getContext('2d', { alpha: true });
c.imageSmoothingEnabled = false;
c.save();
c.translate(-bounds.x, -bounds.y);
for (const tile of tiles) drawTerrainTile(c, tile, worldData);
c.restore();
chunks.push({ canvas, x: bounds.x, y: bounds.y, w: canvas.width, h: canvas.height, tileX: cx, tileY: cy });
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + canvas.width);
maxY = Math.max(maxY, bounds.y + canvas.height);
}
}
const coastTiles = findCoastFoamTiles(worldData);
if (!chunks.length) return { chunks: [], bounds: { x: 0, y: 0, w: 1, h: 1 }, chunkSize, coastTiles };
return { chunks, bounds: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, chunkSize, coastTiles };
}
function findCoastFoamTiles(worldData) {
const out = [];
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let y = 0; y < WORLD_H; y++) {
for (let x = 0; x < WORLD_W; x++) {
const tile = worldData.get(x, y);
if (!tile || tile.type !== 'water') continue;
let touchesLand = false;
for (const [dx, dy] of dirs) {
const near = worldData.get(x + dx, y + dy);
if (near && near.type !== 'water') { touchesLand = true; break; }
}
if (touchesLand) out.push({ x, y });
}
}
return out;
}
function getTerrainChunkBounds(tiles) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const tile of tiles) {
const p = tileToWorld(tile.x, tile.y);
const lift = getTileLift(tile);
minX = Math.min(minX, p.x - TILE_W / 2 - 3);
maxX = Math.max(maxX, p.x + TILE_W / 2 + 3);
minY = Math.min(minY, p.y - lift - 16);
maxY = Math.max(maxY, p.y + TILE_H + 18);
}
minX = Math.floor(minX);
minY = Math.floor(minY);
maxX = Math.ceil(maxX);
maxY = Math.ceil(maxY);
return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
}
function drawTerrainCache(cache) {
const rect = getViewportWorldRect(64);
for (const chunk of cache.chunks) {
if (chunk.x + chunk.w < rect.left || chunk.x > rect.right || chunk.y + chunk.h < rect.top || chunk.y > rect.bottom) continue;
ctx.drawImage(chunk.canvas, chunk.x, chunk.y);
}
}
function buildCoastalFoamTextures() {
return { a: makeFoamTexture(24, 1), b: makeFoamTexture(24, 2) };
}
function makeFoamTexture(size, seedOffset) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const c = canvas.getContext('2d', { alpha: true });
c.clearRect(0, 0, size, size);
for (let i = 0; i < 26; i++) {
const n = pseudoNoise(seedOffset * 93 + i * 17.3);
const x = Math.floor(n * (size - 6)) + 3;
const y = Math.floor(pseudoNoise(seedOffset * 77 + i * 11.1) * (size - 6)) + 3;
const r = 1 + Math.floor(pseudoNoise(seedOffset * 41 + i * 6.2) * 3);
c.fillStyle = i % 2 ? 'rgba(232, 248, 255, 0.42)' : 'rgba(216, 239, 248, 0.32)';
c.beginPath();
c.arc(x, y, r, 0, Math.PI * 2);
c.fill();
c.globalCompositeOperation = 'destination-out';
c.beginPath();
c.arc(x + (i % 2 ? 0.4 : -0.6), y + (i % 3 ? 0.2 : -0.4), Math.max(0.6, r - 1.0), 0, Math.PI * 2);
c.fill();
c.globalCompositeOperation = 'source-over';
}
return canvas;
}
function drawCoastalFoam(time) {
if (!terrainCache?.coastTiles?.length || !coastalFoamTextures) return;
const rect = getViewportWorldRect(64);
for (const tile of terrainCache.coastTiles) {
const pos = tileToWorld(tile.x, tile.y);
if (pos.x + 22 < rect.left || pos.x - 22 > rect.right || pos.y + 18 < rect.top || pos.y - 18 > rect.bottom) continue;
const baseY = pos.y + TILE_H * 0.42;
ctx.save();
ctx.globalAlpha = 0.34;
ctx.translate(pos.x, baseY);
ctx.rotate(time / 12000 + (tile.x + tile.y) * 0.03);
ctx.drawImage(coastalFoamTextures.a, -12, -12);
ctx.restore();
ctx.save();
ctx.globalAlpha = 0.22;
ctx.translate(pos.x + 1, baseY + 1);
ctx.rotate(-(time / 16500) + (tile.x - tile.y) * 0.025);
ctx.drawImage(coastalFoamTextures.b, -12, -12);
ctx.restore();
}
}
function drawTerrainTile(c, tile, worldData) {
const { x, y } = tileToWorld(tile.x, tile.y);
const palette = {
water: ['#6ebfe0', '#69bbdc'],
sand: ['#ead99d', '#e8d293'],
grass: ['#91cf76', '#89c970'],
highland: ['#9db781', '#95ab79']
};
const [base, alt] = palette[tile.type];
const fill = tile.shade > 0 ? base : alt;
const lift = getTileLift(tile);
if (tile.type === 'highland') {
const frontLeft = worldData.get(tile.x, tile.y + 1);
const frontRight = worldData.get(tile.x + 1, tile.y);
const leftLower = !frontLeft || frontLeft.type !== 'highland';
const rightLower = !frontRight || frontRight.type !== 'highland';
if (rightLower) {
c.fillStyle = '#7b8e6c';
c.beginPath();
c.moveTo(x, y + TILE_H - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2);
c.lineTo(x, y + TILE_H);
c.closePath();
c.fill();
}
if (leftLower) {
c.fillStyle = '#6f8263';
c.beginPath();
c.moveTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x, y + TILE_H);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2);
c.closePath();
c.fill();
}
}
c.fillStyle = fill;
c.beginPath();
c.moveTo(x, y - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.closePath();
c.fill();
if (tile.type === 'water') {
c.fillStyle = 'rgba(255,255,255,.045)';
c.beginPath();
c.moveTo(x - TILE_W * .20, y + TILE_H * .48);
c.lineTo(x + TILE_W * .15, y + TILE_H * .31);
c.lineTo(x + TILE_W * .25, y + TILE_H * .38);
c.lineTo(x - TILE_W * .10, y + TILE_H * .55);
c.closePath();
c.fill();
}
c.strokeStyle = 'rgba(36, 48, 68, .02)';
c.lineWidth = 1;
c.beginPath();
c.moveTo(x, y - lift);
c.lineTo(x + TILE_W / 2, y + TILE_H / 2 - lift);
c.lineTo(x, y + TILE_H - lift);
c.lineTo(x - TILE_W / 2, y + TILE_H / 2 - lift);
c.closePath();
c.stroke();
}
function getTileLift(tile) {
return tile?.type === 'highland' ? 12 : 0;
}
function getLiftAtCoord(x, y) {
const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1));
return getTileLift(tile);
}
function getTerrainSurfaceColorAt(x, y) {
const tile = world.get(clamp(Math.floor(x), 0, WORLD_W - 1), clamp(Math.floor(y), 0, WORLD_H - 1));
const palette = {
water: '#8cd2ee',
sand: '#ead99d',
grass: '#91cf76',
highland: '#9db781'
};
return palette[tile?.type] || '#d9d2c0';
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const expanded = Phase2Sync?.isCompactState?.(parsed) ? Phase2Sync.expandState(parsed) : parsed;
if (expanded && Array.isArray(expanded.assets)) return normalizeState(expanded);
}
} catch (error) {
console.warn('Could not load local state.', error);
}
return seedState();
}
function saveState() {
state.schema = SAVE_SCHEMA;
state.guardrails = { ...PHASE5_GUARDRAILS, ...(state.guardrails || {}) };
state.moderationReports = normalizeModerationReports(state.moderationReports || []);
state.settings = { ...defaultVisualSettings(), ...(state.settings || {}) };
state.eventLog = Array.isArray(state.eventLog) ? state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [];
const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
cachePhase2State();
updateSyncStats();
updateGuardrailStats();
}
function normalizeState(input) {
return {
schema: SAVE_SCHEMA,
authorName: input.authorName || 'Local Artist',
assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [],
placed: normalizePlacements(Array.isArray(input.placed) ? input.placed : []),
dynamicSummons: normalizeDynamicSummons(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {},
moderationReports: normalizeModerationReports(input.moderationReports || []),
guardrails: { ...PHASE5_GUARDRAILS, ...(input.guardrails || {}) },
eventLog: Array.isArray(input.eventLog) ? input.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300)) : [],
sync: input.sync || { lastEventId: null },
settings: { ...defaultVisualSettings(), ...(input.settings || {}) }
};
}
function normalizeAsset(asset) {
const size = Number(asset.size) || 16;
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const right = normalizePixels(asset.faces?.right || asset.pixels || blankPixels(size), size);
const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other');
const normalized = {
id: asset.id || uid(),
name: asset.name || 'Untitled',
category,
subtype,
size,
pixels: encodePixels(right),
faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null,
parentAssetId: asset.parentAssetId || null,
originalAssetId: asset.originalAssetId || null,
createdAt: asset.createdAt || Date.now(),
updatedAt: asset.updatedAt || asset.createdAt || Date.now(),
author: asset.author || 'Local Artist',
meta: normalizeAssetMeta(asset.meta || {}, category, subtype, size)
};
normalized.contentHash = computeAssetContentHash(normalized);
return normalized;
}
function normalizeAssetMeta(meta, category, subtype, size) {
const normalizedDepth = normalizeDepthPixels(meta.depthPixels || [], size);
const lightPixels = Array.isArray(meta.lightPixels)
? meta.lightPixels
.map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || meta.lightColor || nearestPaletteCode('#ffd86a') }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
: [];
return buildAssetMeta(category, subtype, normalizedDepth, lightPixels, meta.lightColor || '#ffd86a', meta.door || null, size);
}
function buildAssetMeta(category, subtype, sourceDepthPixels, sourceLightPixels, lightColor, door, size = editorSize) {
const normalizedDepth = normalizeDepthPixels(sourceDepthPixels || [], size);
const hasDepth = normalizedDepth.some(Boolean);
const lightPixelsClean = Array.isArray(sourceLightPixels)
? sourceLightPixels
.map((p) => ({ x: clampInt(p.x, 0, size - 1, 0), y: clampInt(p.y, 0, size - 1, 0), c: p.c || nearestPaletteCode(lightColor || '#ffd86a') }))
.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))
: [];
const hasLight = lightPixelsClean.length > 0;
return {
hasLight,
lightPixels: hasLight ? lightPixelsClean : [],
lightColor: hasLight ? nearestPaletteCode(lightColor || '#ffd86a') : null,
depthPixels: hasDepth ? encodeDepthPixels(normalizedDepth) : null,
door: category === 'static' && subtype === 'building' && door ? { x: clampInt(door.x, 0, size - 1, Math.floor(size / 2)), y: clampInt(door.y, 0, size - 1, size - 1) } : null
};
}
function normalizePlacements(items) {
return dedupeByAsset(items).map((item) => ({
id: item.id || uid(),
assetId: item.assetId,
x: clampInt(item.x, 0, WORLD_W - 1, 0),
y: clampInt(item.y, 0, WORLD_H - 1, 0),
placedAt: item.placedAt || item.createdAt || Date.now(),
version: Number(item.version) || 1
})).filter((item) => item.assetId);
}
function normalizeDynamicSummons(items) {
return dedupeByAsset(items).map((item) => ({
id: item.id || uid(),
assetId: item.assetId,
homeX: clampInt(item.homeX ?? item.x, 0, WORLD_W - 1, 0),
homeY: clampInt(item.homeY ?? item.y, 0, WORLD_H - 1, 0),
createdAt: item.createdAt || item.placedAt || Date.now(),
version: Number(item.version) || 1
})).filter((item) => item.assetId);
}
function dedupeByAsset(items) {
const map = new Map();
for (const item of items) {
if (item && item.assetId) map.set(item.assetId, item);
}
return [...map.values()];
}
function findNearestTerrain(type, startX, startY) {
let best = null;
let bestD = Infinity;
const maxR = Math.max(WORLD_W, WORLD_H);
for (let r = 0; r < maxR; r++) {
for (let y = Math.max(0, startY - r); y <= Math.min(WORLD_H - 1, startY + r); y++) {
for (let x = Math.max(0, startX - r); x <= Math.min(WORLD_W - 1, startX + r); x++) {
const tile = world.get(x, y);
if (!tile || tile.type !== type) continue;
const d = Math.hypot(x - startX, y - startY);
if (d < bestD) { bestD = d; best = { x, y }; }
}
}
if (best) return best;
}
return { x: startX, y: startY };
}
function homeFromPos(pos) {
return { homeX: pos.x, homeY: pos.y };
}
function seedState() {
const assets = [
makeAsset('Hill Cottage', 'static', 'building', drawCottage(), { hasLight: true, lightPixels: [{ x: 8, y: 8 }, { x: 9, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }),
makeAsset('Pine Cluster', 'static', 'nature', drawPineCluster()),
makeAsset('Shell Rock', 'static', 'nature', drawShellRock()),
makeAsset('Wave Skiff', 'static', 'ship', drawShip(), { depthPixels: drawShipDepth(), hasLight: false }),
makeAsset('Fisher Kid', 'dynamic', 'human', drawFisherKidRight()),
makeAsset('Moss Cat', 'dynamic', 'animal', drawCatRight()),
makeAsset('Koi Fish', 'dynamic', 'fish', drawKoiRight()),
makeAsset('Cloud Gull', 'dynamic', 'bird', drawGullRight())
];
const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id]));
const shipPos = findNearestTerrain('water', 62, 58);
const fishPos = findNearestTerrain('water', 66, 60);
return {
schema: SAVE_SCHEMA,
authorName: 'Local Artist',
assets,
placed: [
{ id: uid(), assetId: idByName['Hill Cottage'], x: 36, y: 36, placedAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Pine Cluster'], x: 32, y: 36, placedAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Shell Rock'], x: 39, y: 40, placedAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Wave Skiff'], ...shipPos, placedAt: Date.now(), version: 1 }
],
objectVotes: {},
assetVotes: {},
hiddenAssets: {},
hiddenObjects: {},
moderationReports: [],
guardrails: { ...PHASE5_GUARDRAILS },
settings: defaultVisualSettings(),
dynamicSummons: [
{ id: uid(), assetId: idByName['Fisher Kid'], homeX: 36, homeY: 38, createdAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Moss Cat'], homeX: 33, homeY: 40, createdAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Koi Fish'], ...homeFromPos(fishPos), createdAt: Date.now(), version: 1 },
{ id: uid(), assetId: idByName['Cloud Gull'], homeX: 90, homeY: 30, createdAt: Date.now(), version: 1 }
]
};
}
function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) {
const size = 16;
const id = uid();
const right = alignPixelsToBottom(normalizePixels(pixels, size), size);
const asset = {
id,
name,
category,
subtype,
size,
pixels: encodePixels(right),
faces: category === 'dynamic' ? { right: encodePixels(right), left: 'mirror' } : null,
parentAssetId: null,
originalAssetId: null,
createdAt: Date.now(),
updatedAt: Date.now(),
author: 'Island Team',
meta: buildAssetMeta(category, subtype, meta.depthPixels || [], meta.lightPixels || [], meta.lightColor || '#ffd86a', meta.door || null, size)
};
asset.contentHash = computeAssetContentHash(asset);
return asset;
}
function alignPixelsToBottom(pixels, size) {
const source = normalizePixels(pixels, size);
let maxY = -1;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (source[y * size + x]) maxY = Math.max(maxY, y);
}
}
if (maxY < 0 || maxY === size - 1) return source;
const dy = size - 1 - maxY;
const out = blankPixels(size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const value = source[y * size + x];
if (value && y + dy < size) out[(y + dy) * size + x] = value;
}
}
return out;
}
function exportData() {
els.dataBox.value = JSON.stringify(state, null, 2);
toast('Full JSON exported.');
}
function exportCompactData() {
const payload = Phase2Sync?.compactState ? Phase2Sync.compactState(state) : state;
els.dataBox.value = JSON.stringify(payload);
toast('Compact JSON exported.');
}
function exportSnapshotData() {
if (!Phase2Sync?.makeSnapshot) {
exportData();
return;
}
els.dataBox.value = JSON.stringify(Phase2Sync.makeSnapshot(state));
toast('Snapshot JSON exported.');
}
function exportAssetBundleData() {
if (!Phase2Sync?.makeAssetBundle) {
exportData();
return;
}
els.dataBox.value = JSON.stringify(Phase2Sync.makeAssetBundle(state, state.assets.map((asset) => asset.id)));
toast('Asset bundle JSON exported.');
}
function importData() {
try {
const imported = JSON.parse(els.dataBox.value);
if (imported?.format === Phase2Sync?.SNAPSHOT_FORMAT) {
toast('Snapshot import needs matching assets; import a full/compact save or asset bundle instead.');
return;
}
if (Phase2Sync?.isAssetBundle?.(imported)) {
const importedAssets = Phase2Sync.unpackAssetBundle(imported).map(normalizeAsset);
const byId = new Map(state.assets.map((asset) => [asset.id, asset]));
importedAssets.forEach((asset) => byId.set(asset.id, asset));
state.assets = [...byId.values()];
saveState();
spriteCache.clear();
renderLibrary();
updateSelectedLabel();
toast(`${importedAssets.length} asset${importedAssets.length === 1 ? '' : 's'} imported.`);
return;
}
const expanded = Phase2Sync?.isCompactState?.(imported) ? Phase2Sync.expandState(imported) : imported;
state = normalizeState(expanded);
rebuildWorldIndex();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('JSON imported.');
} catch (error) {
toast('Invalid JSON.');
console.error(error);
}
}
function resetAll() {
if (!confirm('Reset all local assets and island objects?')) return;
localStorage.removeItem(STORAGE_KEY);
state = seedState();
rebuildWorldIndex();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('Local world reset.');
}
function recordSyncEvent(event) {
if (!event) return;
state.eventLog ||= [];
state.eventLog.push(event);
state.eventLog = state.eventLog.slice(-(Phase2Sync?.EVENT_LOG_LIMIT || 300));
state.sync ||= { lastEventId: null };
state.sync.lastEventId = event.id;
}
function cachePhase2State() {
if (!Phase2Sync?.cacheAssets) return;
Phase2Sync.cacheAssets(state.assets || []).catch((error) => console.warn('Phase 2 asset cache failed.', error));
if (Phase2Sync?.makeSnapshot && Phase2Sync?.cacheSnapshot) {
Phase2Sync.cacheSnapshot(Phase2Sync.makeSnapshot(state)).catch((error) => console.warn('Phase 2 snapshot cache failed.', error));
}
}
function getWorldObjectCount() {
return (state.placed?.length || 0) + (state.dynamicSummons?.length || 0);
}
function normalizeModerationReports(input) {
const list = Array.isArray(input) ? input : [];
return list
.map((report) => ({
id: report.id || uid(),
objectId: report.objectId || report.id || '',
assetId: report.assetId || '',
objectKind: report.objectKind === 'dynamic' ? 'dynamic' : 'static',
assetName: report.assetName || 'Untitled',
assetAuthor: report.assetAuthor || 'Local Artist',
reporter: report.reporter || 'local',
reason: report.reason || 'local-report',
createdAt: Number(report.createdAt) || Date.now()
}))
.filter((report) => report.objectId && report.assetId)
.slice(-PHASE5_GUARDRAILS.maxReports);
}
function collectWorldValidation() {
const assetIds = new Set((state.assets || []).map((asset) => asset.id));
const contentCounts = new Map();
for (const asset of state.assets || []) {
const key = asset.contentHash || computeAssetContentHash(asset);
contentCounts.set(key, (contentCounts.get(key) || 0) + 1);
}
const orphanStatic = (state.placed || []).filter((item) => !assetIds.has(item.assetId));
const orphanDynamic = (state.dynamicSummons || []).filter((item) => !assetIds.has(item.assetId));
const invalidTerrain = [];
for (const item of state.placed || []) {
const asset = findAsset(item.assetId);
const tile = world.get(item.x, item.y);
if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'static', id: item.id, assetId: item.assetId, x: item.x, y: item.y });
}
for (const item of state.dynamicSummons || []) {
const asset = findAsset(item.assetId);
const tile = world.get(Math.round(item.homeX), Math.round(item.homeY));
if (asset && tile && !isTerrainCompatible(asset, tile)) invalidTerrain.push({ kind: 'dynamic', id: item.id, assetId: item.assetId, x: item.homeX, y: item.homeY });
}
const duplicateContent = [...contentCounts.values()].filter((count) => count > 1).reduce((sum, count) => sum + count - 1, 0);
return {
schema: SAVE_SCHEMA,
generatedAt: new Date().toISOString(),
assets: state.assets?.length || 0,
worldObjects: getWorldObjectCount(),
hiddenObjects: Object.keys(state.hiddenObjects || {}).length,
reports: state.moderationReports?.length || 0,
duplicateContent,
orphanStatic: orphanStatic.length,
orphanDynamic: orphanDynamic.length,
invalidTerrain: invalidTerrain.length,
invalidTerrainObjects: invalidTerrain,
limits: state.guardrails || PHASE5_GUARDRAILS
};
}
function isTerrainCompatible(asset, tile) {
if (!asset || !tile) return false;
if (asset.category === 'static' && (asset.subtype === 'water' || asset.subtype === 'ship')) return tile.type === 'water';
if (asset.category === 'dynamic' && asset.subtype === 'fish') return tile.type === 'water';
return tile.type !== 'water';
}
function updateGuardrailStats() {
if (!els.guardrailStats) return;
const report = collectWorldValidation();
const assetText = `${report.assets}/${report.limits.maxAssets}`;
const objectText = `${report.worldObjects}/${report.limits.maxWorldObjects}`;
const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain;
els.guardrailStats.textContent = `Assets ${assetText}, objects ${objectText}, reports ${report.reports}, hidden ${report.hiddenObjects}, duplicate-content copies ${report.duplicateContent}, validation warnings ${warnings}.`;
}
function validateWorld() {
const report = collectWorldValidation();
els.dataBox.value = JSON.stringify({ format: 'pixel-island-phase5-validation-v1', ...report }, null, 2);
updateGuardrailStats();
const warnings = report.orphanStatic + report.orphanDynamic + report.invalidTerrain;
toast(warnings ? `Validation found ${warnings} warning(s).` : 'Validation passed.');
}
function exportModerationReport() {
const validation = collectWorldValidation();
const payload = {
format: 'pixel-island-phase5-moderation-v1',
generatedAt: validation.generatedAt,
authorName: state.authorName || 'Local Artist',
reports: normalizeModerationReports(state.moderationReports || []),
hiddenObjects: state.hiddenObjects || {},
downvotedObjects: Object.entries(state.objectVotes || {})
.map(([objectId, votes]) => ({ objectId, up: votes.up || 0, down: votes.down || 0, score: (votes.up || 0) - (votes.down || 0) }))
.filter((item) => item.down > 0),
validation
};
els.dataBox.value = JSON.stringify(payload, null, 2);
toast('Moderation report exported.');
}
function clearModerationReports() {
if (!state.moderationReports?.length) {
toast('No local reports to clear.');
return;
}
if (!confirm('Clear local moderation reports? Hidden objects stay hidden.')) return;
state.moderationReports = [];
saveState();
updateGuardrailStats();
toast('Local reports cleared.');
}
function updateSyncStats() {
if (!els.syncStats) return;
const report = Phase2Sync?.compactSizeReport?.(state);
if (!report) {
els.syncStats.textContent = 'Phase 2 codec unavailable.';
return;
}
const saved = report.savedPercent > 0 ? `${report.savedPercent}% smaller` : 'no saving yet';
const statsRect = getViewportWorldRect(64);
const visibleChunkCount = terrainCache?.chunks?.filter((chunk) => !(chunk.x + chunk.w < statsRect.left || chunk.x > statsRect.right || chunk.y + chunk.h < statsRect.top || chunk.y > statsRect.bottom)).length || 0;
els.syncStats.textContent = `Compact local save: ${report.compactBytes.toLocaleString()} bytes / full ${report.fullBytes.toLocaleString()} bytes (${saved}). Assets ${report.assets}, objects ${report.objects}, queued events ${state.eventLog?.length || 0}. Terrain chunks ${visibleChunkCount}/${terrainCache?.chunks?.length || 0}.`;
}
function computeAssetContentHash(asset) {
const payload = [
asset.category || '',
asset.subtype || '',
asset.size || '',
asset.pixels || '',
asset.faces?.right || '',
asset.faces?.left || '',
asset.meta?.depthPixels || '',
JSON.stringify(asset.meta?.lightPixels || []),
asset.meta?.door ? `${asset.meta.door.x},${asset.meta.door.y}` : ''
].join('|');
return `fnv1a:${fnv1a(payload)}`;
}
function fnv1a(value) {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function clampInt(value, min, max, fallback = min) {
const n = Math.round(Number(value));
return Number.isFinite(n) ? clamp(n, min, max) : fallback;
}
function findAsset(id) {
return state.assets.find((asset) => asset.id === id) || null;
}
function blankPixels(size) {
return Array(size * size).fill(null);
}
function normalizePixels(pixels, size) {
const out = blankPixels(size);
if (typeof pixels === 'string') {
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
const ch = pixels[i];
out[i] = ch === '.' ? null : (PALETTE_BY_CODE[ch] ? ch : nearestPaletteCode(ch));
}
return out;
}
if (!Array.isArray(pixels)) return out;
for (let i = 0; i < Math.min(out.length, pixels.length); i++) {
const value = pixels[i];
if (!value) out[i] = null;
else if (PALETTE_BY_CODE[value]) out[i] = value;
else out[i] = nearestPaletteCode(value);
}
return out;
}
function encodePixels(pixels) {
return normalizePixels(pixels, Math.sqrt(pixels.length) || editorSize).map((value) => value || '.').join('');
}
function colorToHex(value) {
if (!value) return 'rgba(0,0,0,0)';
return PALETTE_BY_CODE[value] || value;
}
function nearestPaletteCode(color) {
if (!color || typeof color !== 'string') return null;
if (PALETTE_BY_CODE[color]) return color;
const rgb = parseHex(color);
if (!rgb) return selectedColorCode;
let best = PALETTE[0].code;
let bestDist = Infinity;
for (const entry of PALETTE) {
const p = parseHex(entry.color);
const dist = (rgb.r - p.r) ** 2 + (rgb.g - p.g) ** 2 + (rgb.b - p.b) ** 2;
if (dist < bestDist) { bestDist = dist; best = entry.code; }
}
return best;
}
function readableTextColor(hex) {
const rgb = parseHex(hex);
if (!rgb) return '#243044';
const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
return yiq > 140 ? '#243044' : '#fffdf5';
}
function parseHex(hex) {
if (typeof hex !== 'string' || !hex.startsWith('#')) return null;
const clean = hex.replace('#', '');
const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean;
const value = parseInt(full, 16);
if (!Number.isFinite(value)) return null;
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
}
function normalizeDepthPixels(input, size) {
const out = Array(size * size).fill(0);
if (typeof input === 'string') {
for (let i = 0; i < Math.min(out.length, input.length); i++) {
const ch = input[i];
out[i] = ch === '1' ? 1 : (ch === '-' || ch === '_' || ch === 'l' || ch === 'L') ? -1 : 0;
}
return out;
}
if (!Array.isArray(input)) return out;
for (let i = 0; i < Math.min(out.length, input.length); i++) {
const value = Number(input[i]) || 0;
out[i] = value > 0 ? 1 : value < 0 ? -1 : 0;
}
return out;
}
function encodeDepthPixels(input) {
return Array.from(input || []).map((v) => Number(v) > 0 ? '1' : Number(v) < 0 ? '-' : '.').join('');
}
function resizeDepthPixels(source, oldSize, newSize) {
const normalized = normalizeDepthPixels(source, oldSize);
const out = Array(newSize * newSize).fill(0);
const min = Math.min(oldSize, newSize);
const xOffset = Math.floor((newSize - min) / 2);
const yOffset = Math.floor((newSize - min) / 2);
const oldOffset = Math.floor((oldSize - min) / 2);
for (let y = 0; y < min; y++) {
for (let x = 0; x < min; x++) {
out[(y + yOffset) * newSize + (x + xOffset)] = normalized[(y + oldOffset) * oldSize + (x + oldOffset)] || 0;
}
}
return out;
}
function getAssetDepth(asset, x, y, side = 'right') {
const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
const sx = asset.category === 'dynamic' && side === 'left' ? asset.size - 1 - x : x;
return depth[y * asset.size + sx] || 0;
}
function applyDepthToColor(hex, depth) {
return shadeAssetPixelColor(hex, depth, 0, 0, 1, renderPhase);
}
function shadeAssetPixelColor(hex, depth, x, y, size, phase) {
const rgb = parseHex(hex);
if (!rgb) return hex;
const activePhase = phase || renderPhase || getPhase();
const dirX = clamp(-(activePhase?.shadow?.dirX || 0), -1, 1);
const vertical = 1 - (y / Math.max(1, size - 1));
const horizontal = ((x / Math.max(1, size - 1)) - 0.5) * dirX;
const sunlight = (activePhase?.key === 'night' ? 8 : activePhase?.key === 'evening' || activePhase?.key === 'morning' ? 12 : 16);
const depthBoost = depth * sunlight;
const exposure = (vertical * 0.7 + horizontal * 0.5) * (activePhase?.key === 'night' ? 9 : 13);
const amt = depthBoost + exposure;
const r = clamp(rgb.r + amt, 0, 255);
const g = clamp(rgb.g + amt, 0, 255);
const b = clamp(rgb.b + amt, 0, 255);
return `#${Math.round(r).toString(16).padStart(2,'0')}${Math.round(g).toString(16).padStart(2,'0')}${Math.round(b).toString(16).padStart(2,'0')}`;
}
function resizePixels(source, oldSize, newSize) {
const out = blankPixels(newSize);
const min = Math.min(oldSize, newSize);
const xOffset = Math.floor((newSize - min) / 2);
const yOffset = Math.floor((newSize - min) / 2);
const oldOffset = Math.floor((oldSize - min) / 2);
for (let y = 0; y < min; y++) {
for (let x = 0; x < min; x++) {
out[(y + yOffset) * newSize + (x + xOffset)] = source[(y + oldOffset) * oldSize + (x + oldOffset)] || null;
}
}
return out;
}
function mirrorPixels(pixels, size) {
const out = blankPixels(size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
out[y * size + (size - 1 - x)] = pixels[y * size + x] || null;
}
}
return out;
}
function hasAnyPixel(pixels) {
return Array.isArray(pixels) && pixels.some(Boolean);
}
function fade(t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function hash2(ix, iy) {
let h = ix * 374761393 + iy * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return (h ^ (h >> 16)) >>> 0;
}
function gradDot(ix, iy, x, y) {
const h = hash2(ix, iy) & 7;
const gx = [1, -1, 1, -1, 1, -1, 0, 0][h];
const gy = [1, 1, -1, -1, 0, 0, 1, -1][h];
return gx * (x - ix) + gy * (y - iy);
}
function perlinNoise(x, y) {
const x0 = Math.floor(x), y0 = Math.floor(y);
const x1 = x0 + 1, y1 = y0 + 1;
const sx = fade(x - x0), sy = fade(y - y0);
const n00 = gradDot(x0, y0, x, y);
const n10 = gradDot(x1, y0, x, y);
const n01 = gradDot(x0, y1, x, y);
const n11 = gradDot(x1, y1, x, y);
const ix0 = lerp(n00, n10, sx);
const ix1 = lerp(n01, n11, sx);
return lerp(ix0, ix1, sy) * .5 + .5;
}
function fractalPerlin(x, y, octaves = 4) {
let amp = .5;
let freq = 1;
let total = 0;
let norm = 0;
for (let i = 0; i < octaves; i++) {
total += perlinNoise(x * freq, y * freq) * amp;
norm += amp;
amp *= .5;
freq *= 2;
}
return total / norm;
}
function smoothNoise(x, y) {
return Math.sin(x * .71 + y * .37) * .5 + Math.sin(x * .23 - y * .61) * .32 + Math.sin((x + y) * .17) * .18;
}
function cap(value) {
return `${value}`.charAt(0).toUpperCase() + `${value}`.slice(1);
}
function buildPalette() {
const neutrals = ['#fffdf7','#f3ead8','#dfd2bc','#c2b39d','#9b9083','#776d67','#5a5454','#403d42','#2d2d35','#14151c'];
const hues = [0, 24, 48, 72, 96, 132, 168, 204, 228, 264, 288, 324, 348];
const lights = [72, 58, 46, 34];
const colors = [...neutrals];
for (const light of lights) {
for (const hue of hues) colors.push(hslToHex(hue, 72, light));
}
return COLOR_CODES.split('').map((code, index) => ({ code, color: colors[index] }));
}
function pseudoNoise(value) {
const x = Math.sin(value * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = (n) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const toHex = (value) => Math.round(255 * value).toString(16).padStart(2, '0');
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}
function hexToRgba(hex, alpha) {
const clean = hex.replace('#', '');
const bigint = parseInt(clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function hexToRgbParts(hex) {
const clean = String(hex || '#000000').replace('#', '');
const expanded = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean.padEnd(6, '0').slice(0, 6);
const bigint = parseInt(expanded, 16);
return { r: (bigint >> 16) & 255, g: (bigint >> 8) & 255, b: bigint & 255 };
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s; const l = (max + min) / 2;
if (max === min) { h = 0; s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
default: h = (r - g) / d + 4; break;
}
h *= 60;
}
return { h, s: s * 100, l: l * 100 };
}
function varyHexColor(hex, hueRange = 12, satRange = 10, lightRange = 10) {
const rgb = parseHex(hex);
if (!rgb) return hex;
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
const h = mod(hsl.h + (Math.random() * 2 - 1) * hueRange, 360);
const s = clamp(hsl.s + (Math.random() * 2 - 1) * satRange, 14, 100);
const l = clamp(hsl.l + (Math.random() * 2 - 1) * lightRange, 10, 94);
return hslToHex(h, s, l);
}
function mixHex(a, b, t) {
const ca = hexToRgbParts(a);
const cb = hexToRgbParts(b);
const toHex = (value) => Math.round(value).toString(16).padStart(2, '0');
return `#${toHex(lerp(ca.r, cb.r, t))}${toHex(lerp(ca.g, cb.g, t))}${toHex(lerp(ca.b, cb.b, t))}`;
}
function toast(message) {
clearTimeout(toastTimer);
els.toast.textContent = message;
els.toast.hidden = false;
toastTimer = setTimeout(() => { els.toast.hidden = true; }, 1900);
}
function px(pixels, size, x, y, color) {
if (x >= 0 && x < size && y >= 0 && y < size) pixels[y * size + x] = color;
}
function rect(pixels, size, x, y, w, h, color) {
for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) px(pixels, size, xx, yy, color);
}
function circle(pixels, size, cx, cy, r, color) {
for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) {
for (let x = Math.floor(cx - r); x <= Math.ceil(cx + r); x++) {
if ((x - cx) ** 2 + (y - cy) ** 2 <= r ** 2) px(pixels, size, x, y, color);
}
}
}
function drawCottage() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 8, 8, 6, '#b86b4f');
rect(p, s, 5, 9, 6, 4, '#d68b5e');
rect(p, s, 6, 11, 3, 3, '#3b3348');
rect(p, s, 10, 9, 2, 2, '#ffe38a');
rect(p, s, 3, 7, 10, 1, '#6a485d');
for (let y = 3; y <= 7; y++) {
const inset = Math.abs(6 - y);
for (let x = 3 + inset; x <= 12 - inset; x++) px(p, s, x, y, y < 5 ? '#8e4f61' : '#74465b');
}
px(p, s, 6, 8, '#ffcf75'); px(p, s, 11, 12, '#7b493f');
return p;
}
function drawPineCluster() {
const s = 16, p = blankPixels(s);
rect(p, s, 6, 9, 2, 5, '#765238');
rect(p, s, 10, 10, 2, 4, '#765238');
for (let y = 2; y <= 10; y++) {
const w = Math.floor((y + 1) / 2);
for (let x = 7 - w; x <= 7 + w; x++) px(p, s, x, y, y % 2 ? '#348f5a' : '#42aa68');
}
for (let y = 5; y <= 11; y++) {
const w = Math.floor((y - 2) / 2);
for (let x = 11 - w; x <= 11 + w; x++) px(p, s, x, y, y % 2 ? '#2f7f55' : '#3d9d62');
}
px(p, s, 5, 4, '#8ee68c'); px(p, s, 10, 6, '#8ee68c');
return p;
}
function drawShellRock() {
const s = 16, p = blankPixels(s);
rect(p, s, 5, 10, 7, 3, '#b9ad9f');
rect(p, s, 4, 11, 9, 2, '#968b83');
px(p, s, 6, 9, '#f0e0ca'); px(p, s, 8, 9, '#f0e0ca'); px(p, s, 10, 9, '#f0e0ca');
rect(p, s, 7, 8, 4, 2, '#ffb6b1');
px(p, s, 9, 7, '#ffd7cb'); px(p, s, 11, 10, '#6d625f');
return p;
}
function drawShip() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 10, 8, 2, '#7d513c');
rect(p, s, 5, 12, 6, 1, '#50392f');
px(p, s, 3, 9, '#7d513c'); px(p, s, 12, 9, '#7d513c');
rect(p, s, 8, 4, 1, 6, '#5b4b48');
for (let y = 4; y <= 8; y++) for (let x = 9; x <= 12 - Math.floor((y - 4) / 2); x++) px(p, s, x, y, '#f3ead8');
for (let y = 5; y <= 8; y++) for (let x = 5 + Math.floor((y - 5) / 2); x <= 7; x++) px(p, s, x, y, '#ffd66e');
px(p, s, 10, 5, '#ffffff'); px(p, s, 6, 6, '#fff7d1');
return p;
}
function drawShipDepth() {
const s = 16, d = Array(s * s).fill(0);
for (let y = 10; y <= 12; y++) for (let x = 4; x <= 12; x++) d[y * s + x] = -1;
for (let y = 4; y <= 8; y++) d[y * s + 8] = 1;
return d;
}
function drawFisherKidRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 3, 3, 3, '#d9a06c');
rect(p, s, 6, 6, 5, 4, '#e2b94f');
rect(p, s, 6, 10, 2, 4, '#31577e');
rect(p, s, 9, 10, 2, 4, '#31577e');
rect(p, s, 6, 2, 5, 1, '#35434a');
px(p, s, 10, 4, '#202631');
px(p, s, 12, 7, '#5b4b48'); px(p, s, 13, 8, '#5b4b48');
return p;
}
function drawCatRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 8, 7, 4, '#5c8a57');
rect(p, s, 10, 7, 3, 3, '#6fa064');
px(p, s, 10, 6, '#6fa064'); px(p, s, 12, 6, '#6fa064');
px(p, s, 12, 8, '#1e2430'); px(p, s, 13, 9, '#d9a06c');
rect(p, s, 5, 12, 1, 2, '#3e633e'); rect(p, s, 9, 12, 1, 2, '#3e633e');
px(p, s, 3, 8, '#5c8a57'); px(p, s, 2, 7, '#5c8a57');
return p;
}
function drawKoiRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 5, 7, 6, 3, '#f7f2e8');
px(p, s, 6, 7, '#ff7d5a'); px(p, s, 8, 8, '#ff7d5a'); px(p, s, 10, 9, '#ff7d5a');
px(p, s, 11, 8, '#1d2530');
px(p, s, 4, 7, '#ffb165'); px(p, s, 3, 6, '#ffb165'); px(p, s, 3, 10, '#ffb165');
px(p, s, 7, 6, '#fffdf7'); px(p, s, 9, 10, '#fffdf7');
return p;
}
function drawGullRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 6, 3, 3, '#fffdf7');
rect(p, s, 4, 6, 3, 1, '#dfe6ee');
rect(p, s, 10, 5, 4, 1, '#dfe6ee');
px(p, s, 10, 7, '#f0a45c');
px(p, s, 8, 5, '#1e2430');
px(p, s, 6, 8, '#c9d2dc'); px(p, s, 9, 8, '#c9d2dc');
return p;
}
bootstrap();
})();