pixel/app.js
2026-06-01 21:17:09 +09:00

2713 lines
103 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

(() => {
'use strict';
console.info('Pixel Island Summoner loaded');
const STORAGE_KEY = 'pixel-island-summoner:reset';
const SAVE_SCHEMA = 1;
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 COLOR_CODES = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PALETTE = buildPalette();
const PALETTE_BY_CODE = Object.fromEntries(PALETTE.map((p) => [p.code, p.color]));
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 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', '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'), 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'),
toolLight: $('toolLight'), toolDoor: $('toolDoor'), toolDepth: $('toolDepth'), toggleAdvanced: $('toggleAdvanced'), advancedHint: $('advancedHint'), clearPaint: $('clearPaint'),
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')
};
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
};
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 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 mousePaint = { active: false, panning: false, lastX: 0, lastY: 0, button: 0 };
let shadowCanvasCache = new WeakMap();
let selectedObject = 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 };
function bootstrap() {
resizeCanvas();
resetView(false);
hydrateRuntime();
wireUI();
renderPalette();
hydrateAuthorUI();
refreshCategoryUI();
setupEditor(8, blankPixels(8), blankPixels(8));
renderLibrary();
updateSelectedLabel();
requestAnimationFrame(tick);
}
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.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);
els.assetSize.addEventListener('change', () => {
const nextSize = Number(els.assetSize.value);
setupEditor(nextSize, resizePixels(getActiveEditorPixels(), editorSize, nextSize), resizePixels(editorLeftPixels, editorSize, nextSize));
doorPixel = { x: Math.min(doorPixel.x, nextSize - 1), y: Math.min(doorPixel.y, nextSize - 1) };
depthPixels = resizeDepthPixels(depthPixels, editorSize, nextSize);
lightPixels = lightPixels.filter((p) => p.x < nextSize && p.y < nextSize);
resetEditorView();
drawEditor();
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.toolLight.addEventListener('click', () => setPaintTool('light'));
els.toolDoor.addEventListener('click', () => setPaintTool('door'));
els.toolDepth?.addEventListener('click', () => setPaintTool('depth'));
els.toggleAdvanced?.addEventListener('click', () => {
advancedDraw = !advancedDraw;
toggleHidden(els.toolLight, !advancedDraw);
toggleHidden(els.toolDepth, !advancedDraw);
toggleHidden(els.advancedHint, !advancedDraw);
els.toggleAdvanced.classList.toggle('active', advancedDraw);
if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light')) setPaintTool('brush');
drawEditor();
});
els.clearPaint.addEventListener('click', () => {
if (editingSide === 'left') editorLeftPixels = blankPixels(editorSize);
else editorPixels = blankPixels(editorSize);
depthPixels = blankPixels(editorSize).map(() => 0);
lightPixels = [];
drawEditor();
});
els.voteUp?.addEventListener('click', () => voteSelected(1));
els.voteDown?.addEventListener('click', () => voteSelected(-1));
els.bubbleRemix?.addEventListener('click', () => remixSelected());
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; });
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.resetAll.addEventListener('click', resetAll);
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.',
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 {
const hasLeft = hasAnyPixel(editorLeftPixels);
els.settingsSummary.textContent = `${cap(role)} / ▶ Right first / ◀ Left ${hasLeft ? 'ready' : 'auto-mirror suggested'}.`;
}
}
function setPaintTool(tool) {
paintTool = tool;
[els.toolBrush, els.toolErase, els.toolLight, els.toolDoor, els.toolDepth].filter(Boolean).forEach((button) => button.classList.remove('active'));
({ brush: els.toolBrush, erase: els.toolErase, 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.',
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 depth values onto pixels. Shift/right-click erases depth.'
};
els.editHint.textContent = hints[tool];
}
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();
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()
};
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 getDrawableItems(time = performance.now()) {
const items = [];
for (const placed of state.placed) {
const asset = findAsset(placed.assetId);
if (!asset || state.hiddenAssets?.[asset.id] || state.hiddenObjects?.[placed.id]) continue;
items.push({ kind: 'static', asset, x: placed.x + .5, y: placed.y + .5, 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;
items.push({ kind: 'dynamic', asset, x: runtime.x, y: runtime.y, source: runtime });
}
items.sort((a, b) => (a.x + a.y) - (b.x + b.y) || a.y - b.y);
return items;
}
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);
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
const info = getSpriteDrawInfo(item, time, false);
const margin = item.kind === 'dynamic' ? 18 : 8;
const left = info.drawX - margin;
const top = info.drawY - margin;
const right = info.drawX + info.sprite.width + margin;
const bottom = info.drawY + info.sprite.height + margin;
if (worldX >= left && worldX <= right && worldY >= top && worldY <= bottom) {
return { kind: item.kind, id: item.source.id, assetId: item.asset.id };
}
}
return null;
}
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 = state.placed.filter((p) => p.x === hoverTile.x && p.y === hoverTile.y).length;
const dynamicCount = state.dynamicSummons.filter((p) => Math.round(p.homeX) === hoverTile.x && Math.round(p.homeY) === hoverTile.y).length;
els.tileInfo.textContent = `Tile ${hoverTile.x}, ${hoverTile.y}\nTerrain: ${cap(tile.type)}\nObjects here: ${staticCount}\nDynamic homes: ${dynamicCount}`;
}
function inspectAt(x, y) {
const tile = world.get(x, y);
const staticObjects = state.placed.filter((p) => p.x === x && p.y === y);
const dynamicObjects = state.dynamicSummons.filter((p) => Math.round(p.homeX) === x && Math.round(p.homeY) === y);
const picked = [...dynamicObjects.map((p) => ({ ...p, objectKind: 'dynamic' })), ...staticObjects.map((p) => ({ ...p, objectKind: 'static' }))].at(-1);
if (picked) {
selectWorldObject(picked.objectKind, picked.id, picked.assetId, performance.now());
const asset = findAsset(picked.assetId);
toast(asset ? `Selected ${asset.name}.` : 'Selected object.');
} else {
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: ${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;
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();
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() };
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();
toast(`${asset.name} moved.`);
} else {
const summon = { id: uid(), assetId: asset.id, homeX: x, homeY: y, createdAt: Date.now() };
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());
}
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' && tile.type !== 'water') {
toast('Water 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')) {
toast('Use land for this asset.');
return false;
}
}
return true;
}
function eraseAt(x, y) {
for (let i = state.placed.length - 1; i >= 0; i--) {
const item = state.placed[i];
if (item.x === x && item.y === y) {
const asset = findAsset(item.assetId);
state.placed.splice(i, 1);
if (selectedObject?.id === item.id) selectedObject = null;
saveState();
toast(`${asset?.name ?? 'Object'} removed.`);
return;
}
}
for (let i = state.dynamicSummons.length - 1; i >= 0; i--) {
const item = state.dynamicSummons[i];
if (Math.round(item.homeX) === x && Math.round(item.homeY) === y) {
const asset = findAsset(item.assetId);
state.dynamicSummons.splice(i, 1);
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);
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);
if (selectedObject?.id === objectId) selectedObject = null;
hydrateRuntime();
saveState();
updateSelectionBubble(performance.now());
toast(`${asset?.name ?? 'Dynamic object'} removed.`);
}
}
}
function onPaintPointerDown(event) {
event.preventDefault();
els.paintCanvas.setPointerCapture?.(event.pointerId);
lastPaintedKey = '';
const isPan = event.button === 1 || event.button === 2;
editorPointer = { panning: isPan, pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY };
isPainting = !isPan;
mousePaint.active = false;
mousePaint.panning = false;
if (isPainting) paintAtClient(event.clientX, event.clientY, event.button || 0, event.shiftKey);
}
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 (!isPainting) return;
if (event.buttons !== undefined && (event.buttons & 1) === 0) return;
event.preventDefault();
paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
}
function onPaintPointerUp(event) {
if (editorPointer.pointerId === event.pointerId) editorPointer.panning = false;
isPainting = false;
lastPaintedKey = '';
}
function onPaintClick(event) {
if (event.button !== 0) return;
event.preventDefault();
lastPaintedKey = '';
paintAtClient(event.clientX, event.clientY, 0, event.shiftKey);
}
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;
mousePaint = { active: !isPan, panning: isPan, lastX: event.clientX, lastY: event.clientY, button: event.button };
if (mousePaint.active) paintAtClient(event.clientX, event.clientY, event.button, event.shiftKey);
}
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 = '';
}
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},${shiftKey}`;
if (key === lastPaintedKey) return;
lastPaintedKey = key;
const pixels = getActiveEditorPixels();
const index = y * editorSize + x;
if (paintTool === 'brush') {
pixels[index] = selectedColorCode;
} else if (paintTool === 'erase') {
pixels[index] = null;
removeLightPixel(x, y);
} else if (paintTool === 'light') {
if (shiftKey || button === 2) removeLightPixel(x, y);
else addLightPixel(x, y, selectedColorCode);
updateSettingsSummary();
} else if (paintTool === 'depth') {
depthPixels[index] = (shiftKey || button === 2) ? 0 : 1;
} else if (paintTool === 'door') {
if (roleToCategory(currentRole()) === 'static' && currentRole() === 'building') {
doorPixel = { x, y };
updateSettingsSummary();
}
}
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;
return {
x: (sx - editorView.x) / editorView.zoom,
y: (sy - editorView.y) / editorView.zoom
};
}
function getActiveEditorPixels() {
return editingSide === 'left' ? editorLeftPixels : editorPixels;
}
function setupEditor(size, rightPixels, leftPixels, nextDepthPixels = null) {
editorSize = size;
editorPixels = normalizePixels(rightPixels, size);
editorLeftPixels = normalizePixels(leftPixels, size);
depthPixels = Array.isArray(nextDepthPixels) ? 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();
}
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 = getActiveEditorPixels();
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 depth = depthPixels[y * editorSize + x] || 0;
if (!depth) continue;
pctx.fillStyle = 'rgba(34, 116, 255, .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 = 'rgba(6, 28, 75, .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 lx = (light.x + .5) * cell;
const ly = (light.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);
}
}
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),
leftPixels: shiftPixelsVertical(editorLeftPixels, 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;
}
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: encodePixels(hasAnyPixel(aligned.leftPixels) ? aligned.leftPixels : mirrorPixels(aligned.rightPixels, size))
} : null,
meta: {
hasLight: aligned.lightPixels.length > 0,
lightPixels: aligned.lightPixels.map((p) => ({ x: Math.floor(p.x), y: Math.floor(p.y), c: p.c || selectedColorCode })),
lightColor: selectedColorCode,
depthPixels: encodeDepthPixels(aligned.depthPixels),
door: category === 'static' && subtype === 'building' ? aligned.door : null
}
};
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;
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), blankPixels(8));
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', '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 = `<strong></strong><span>${displayCategory(asset)} / ${cap(asset.subtype)} / ${asset.size}×${asset.size}${lineage}</span><span></span>`;
meta.querySelector('strong').textContent = asset.name;
meta.querySelectorAll('span')[1].textContent = `Author: ${asset.author || 'Local Artist'} · Remixed: ${getRemixCount(asset.id)}`;
card.addEventListener('click', () => {
selectedAssetId = asset.id;
updateSelectedLabel();
renderLibrary();
focusAssetInWorld(asset);
});
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
const up = makeButton(`${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
const down = makeButton(`${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
down.classList.toggle('mutedVote', assetPreviousVote > 0);
const hide = makeButton('Hide', (event) => { event.stopPropagation(); hideAsset(asset.id); });
hide.classList.toggle('mutedAction', assetPreviousVote >= 0);
const copy = makeButton(((asset.author || 'Local Artist') === (state.authorName || 'Local Artist')) ? 'Edit' : 'Copy Edit', (event) => {
event?.stopPropagation?.();
copyEdit(asset);
});
const del = makeButton('Delete', (event) => {
event?.stopPropagation?.();
deleteAsset(asset);
});
del.classList.add('danger');
actions.append(up, down, hide, copy, del);
meta.append(actions);
card.append(preview, meta);
return card;
}
function renderHiddenAssets() {
if (!els.hiddenAssetList) return;
els.hiddenAssetList.innerHTML = '';
const hidden = state.assets.filter((asset) => state.hiddenAssets?.[asset.id]);
if (!hidden.length) {
els.hiddenAssetList.textContent = 'No hidden assets.';
return;
}
for (const asset of hidden) {
const row = document.createElement('article');
row.className = 'assetCard hiddenAssetCard';
const preview = document.createElement('canvas');
preview.className = 'assetPreview';
preview.width = 64;
preview.height = 64;
drawPreview(preview, asset);
const meta = document.createElement('div');
meta.className = 'assetMeta';
meta.innerHTML = `<strong></strong><span>Author: ${asset.author || 'Local Artist'}</span>`;
meta.querySelector('strong').textContent = asset.name;
const actions = document.createElement('div');
actions.className = 'assetActions';
const restore = makeButton('Show again', (event) => {
event.stopPropagation();
delete state.hiddenAssets[asset.id];
saveState();
renderLibrary();
toast('Asset shown again.');
});
actions.append(restore);
meta.append(actions);
row.append(preview, meta);
els.hiddenAssetList.append(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', () => {
selectedColorCode = entry.code;
els.paintColor.value = entry.color;
renderPalette();
drawEditor();
});
els.paletteGrid.append(swatch);
}
els.paintColor.value = PALETTE_BY_CODE[selectedColorCode] || '#6bd06b';
}
function displayCategory(asset) {
return asset.category === 'dynamic' ? 'People & Animals' : '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 = asset.faces?.right || asset.pixels || blankPixels(asset.size);
const left = asset.faces?.left || mirrorPixels(right, asset.size);
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, left, depthPixels);
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: 1,
hiddenUntil: 0,
seed: Math.random() * 9999,
nextDecisionAt: 0,
nextBubbleAt: 800 + Math.random() * 1500
};
}).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);
for (const item of dynamicRuntime) {
const asset = findAsset(item.assetId);
if (!asset) continue;
if (time < item.hiddenUntil) continue;
if (asset.subtype === 'fish' && time > (item.nextBubbleAt || 0)) {
spawnFishBubbleCluster(item.x, item.y, time, item.seed);
item.nextBubbleAt = time + 1100 + Math.random() * 1800;
}
const distance = Math.hypot(item.targetX - item.x, item.targetY - item.y);
if (distance < .15 || time > item.nextDecisionAt) chooseTarget(item, asset, time);
const speed = ({ human: .77, animal: .67, car: .37, fish: .47, bird: .75 }[asset.subtype] || .53) * dt;
const dx = item.targetX - item.x;
const dy = item.targetY - item.y;
const len = Math.hypot(dx, dy) || 1;
item.vx = dx === 0 ? item.vx : Math.sign(dx);
item.x += (dx / len) * speed;
item.y += (dy / len) * speed;
if (asset.subtype === 'human' && distance < .3 && Math.random() < .004) {
item.hiddenUntil = time + 1700 + Math.random() * 2200;
}
}
}
function chooseTarget(item, asset, time) {
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.nextDecisionAt = time + 1600 + Math.random() * 2800;
}
function findNearestPlaced(x, y, subtype, maxDistance) {
let best = null;
let bestDistance = maxDistance;
for (const placed of state.placed) {
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 tick(time) {
const dt = Math.min(.05, (time - lastRuntimeUpdate) / 1000);
lastRuntimeUpdate = time;
updateDynamicRuntime(dt, time);
updateClock();
render(time);
requestAnimationFrame(tick);
}
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() {
const t = mod(Date.now(), DAY_MS);
const minute = t / 60000;
const stops = [
{ at: 0, key: 'morning', label: 'Morning', darkness: 0.18, tint: [255, 208, 144, 0.12] },
{ at: 1, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
{ at: 5, key: 'day', label: 'Day', darkness: 0.00, tint: [255, 255, 255, 0.00] },
{ at: 6, key: 'evening', label: 'Evening', darkness: 0.18, tint: [255, 146, 114, 0.14] },
{ at: 10, key: 'night', label: 'Night', darkness: 0.48, tint: [36, 47, 96, 0.18] }
];
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; }
if (minute >= 6) { a = stops[3]; b = stops[4]; }
}
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 < 1 ? 'Morning' : minute < 5 ? 'Day' : minute < 6 ? 'Evening' : 'Night';
return {
key: label.toLowerCase(),
label,
progress: t / DAY_MS,
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)})`,
shadow: getShadowForMinute(minute)
};
}
function render(time = performance.now()) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#86d5ff';
ctx.fillRect(0, 0, cw, ch);
const phase = getPhase();
renderPhase = phase;
ctx.save();
ctx.translate(view.x, view.y);
ctx.scale(view.zoom, view.zoom);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(terrainCache.canvas, 0, 0);
drawHoverTile();
const lightSources = [];
drawObjects(time, lightSources, phase);
drawBubbleParticles(time);
drawSpawnEffects(time);
drawConfettiParticles(time);
ctx.restore();
updateSelectionBubble(time);
if (phase.tint !== 'rgba(255, 255, 255, 0)') {
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);
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);
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);
}
}
}
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') bob += Math.sin(time / 900 + item.x * .7) * 1.6;
if (asset.category === 'dynamic') {
const runtime = item.source;
side = runtime.vx < 0 ? 'left' : 'right';
if (asset.subtype === 'human' || asset.subtype === 'animal') {
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 drawY = pos.y + TILE_H / 2 - sprite.height + bob;
return { asset, pos, sprite, drawX, drawY, alpha, angle, side, bob };
}
function drawSpriteItem(item, time, lightSources, underwater, phase) {
const { asset, pos, sprite, drawX, drawY, alpha, angle } = getSpriteDrawInfo(item, time, true);
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 wx = drawX + (light.x + 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(12, 20, asset.size / 64) });
}
}
}
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;
}
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 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();
ctx.globalCompositeOperation = 'lighter';
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;
if (sx < -radius || sy < -radius || sx > cw + radius || sy > ch + radius) continue;
const gradient = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius);
gradient.addColorStop(0, hexToRgba(source.color, .18 + darkness * .10));
gradient.addColorStop(.35, hexToRgba(source.color, .08));
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(sx, sy, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function getSpriteCanvas(asset, side) {
const key = `${asset.id}:${side}:${asset.category === 'static' ? STATIC_SCALE : DYNAMIC_SCALE}`;
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 = applyDepthToColor(colorToHex(color), getAssetDepth(asset, x, y));
c.fillRect(x * scale, y * scale, scale, scale);
}
}
spriteCache.set(key, canvas);
return canvas;
}
function getAssetPixels(asset, side = 'right') {
if (asset.category === 'dynamic') {
if (side === 'left') return normalizePixels(asset.faces?.left || mirrorPixels(normalizePixels(asset.faces?.right || asset.pixels || [], asset.size), asset.size), asset.size);
return normalizePixels(asset.faces?.right || asset.pixels || blankPixels(asset.size), asset.size);
}
return normalizePixels(asset.pixels || blankPixels(asset.size), asset.size);
}
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 max = tileToWorld(WORLD_W + 1, WORLD_H + 1);
const width = ORIGIN_X + WORLD_W * TILE_W / 2 + 160;
const height = max.y + TILE_H * 4;
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(width);
canvas.height = Math.ceil(height);
const c = canvas.getContext('2d');
c.imageSmoothingEnabled = false;
c.fillStyle = '#74c8e4';
c.fillRect(0, 0, canvas.width, canvas.height);
for (const tile of worldData.tiles) {
drawTerrainTile(c, tile, worldData);
}
return { canvas, bounds: { x: 0, y: 0, w: canvas.width, h: canvas.height } };
}
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 loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.schema === SAVE_SCHEMA && Array.isArray(parsed.assets)) return normalizeState(parsed);
}
} catch (error) {
console.warn('Could not load local state.', error);
}
return seedState();
}
function saveState() {
state.schema = SAVE_SCHEMA;
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function normalizeState(input) {
return {
schema: SAVE_SCHEMA,
authorName: input.authorName || 'Local Artist',
assets: Array.isArray(input.assets) ? input.assets.map(normalizeAsset) : [],
placed: dedupeByAsset(Array.isArray(input.placed) ? input.placed : []),
dynamicSummons: dedupeByAsset(Array.isArray(input.dynamicSummons) ? input.dynamicSummons : []),
objectVotes: input.objectVotes || {},
assetVotes: input.assetVotes || {},
hiddenAssets: input.hiddenAssets || {},
hiddenObjects: input.hiddenObjects || {}
};
}
function normalizeAsset(asset) {
const size = Number(asset.size) || 16;
const category = asset.category === 'dynamic' ? 'dynamic' : 'static';
const base = normalizePixels(asset.pixels || asset.faces?.right || blankPixels(size), size);
const right = normalizePixels(asset.faces?.right || base, size);
const left = normalizePixels(asset.faces?.left || mirrorPixels(right, size), size);
return {
id: asset.id || uid(),
name: asset.name || 'Untitled',
category,
subtype: asset.subtype || (category === 'dynamic' ? 'human' : 'other'),
size,
pixels: encodePixels(base),
faces: category === 'dynamic' ? { right: encodePixels(right), left: encodePixels(left) } : null,
parentAssetId: asset.parentAssetId || null,
originalAssetId: asset.originalAssetId || null,
createdAt: asset.createdAt || Date.now(),
author: asset.author || 'Local Artist',
meta: {
hasLight: Boolean(asset.meta?.hasLight || (asset.meta?.lightPixels || []).length),
lightPixels: Array.isArray(asset.meta?.lightPixels) ? asset.meta.lightPixels.map((p) => ({ x: Number(p.x), y: Number(p.y), c: p.c || asset.meta?.lightColor || nearestPaletteCode('#ffd86a') })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) : [],
lightColor: nearestPaletteCode(asset.meta?.lightColor || '#ffd86a'),
depthPixels: encodeDepthPixels(normalizeDepthPixels(asset.meta?.depthPixels || [], size)),
door: asset.meta?.door || null
}
};
}
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('Cozy House', 'static', 'building', drawHouse(), { hasLight: true, lightPixels: [{ x: 10, y: 8 }, { x: 11, y: 8 }], lightColor: '#ffd86a', door: { x: 7, y: 14 } }),
makeAsset('Round Tree', 'static', 'nature', drawTree()),
makeAsset('Dock Lamp', 'static', 'water', drawLantern(), { hasLight: true, lightPixels: [{ x: 7, y: 6 }, { x: 8, y: 6 }], lightColor: '#b8e8ff' }),
makeAsset('Traveler', 'dynamic', 'human', drawHumanRight(), {}, drawHumanLeft()),
makeAsset('Island Pup', 'dynamic', 'animal', drawDogRight(), {}, drawDogLeft()),
makeAsset('Blue Fish', 'dynamic', 'fish', drawFishRight(), {}, drawFishLeft()),
makeAsset('Tiny Bird', 'dynamic', 'bird', drawBirdRight(), {}, drawBirdLeft())
];
const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id]));
return {
schema: SAVE_SCHEMA,
authorName: 'Local Artist',
assets,
placed: [
{ id: uid(), assetId: idByName['Cozy House'], x: 36, y: 36, placedAt: Date.now() },
{ id: uid(), assetId: idByName['Round Tree'], x: 32, y: 36, placedAt: Date.now() },
{ id: uid(), assetId: idByName['Dock Lamp'], ...findNearestTerrain('water', 62, 58), placedAt: Date.now() }
],
objectVotes: {},
assetVotes: {},
hiddenAssets: {},
hiddenObjects: {},
dynamicSummons: [
{ id: uid(), assetId: idByName['Traveler'], homeX: 36, homeY: 38, createdAt: Date.now() },
{ id: uid(), assetId: idByName['Island Pup'], homeX: 32, homeY: 40, createdAt: Date.now() },
{ id: uid(), assetId: idByName['Blue Fish'], ...homeFromPos(findNearestTerrain('water', 63, 60)), createdAt: Date.now() },
{ id: uid(), assetId: idByName['Tiny Bird'], homeX: 90, homeY: 30, createdAt: Date.now() }
]
};
}
function makeAsset(name, category, subtype, pixels, meta = {}, leftPixels = null) {
const size = 16;
const id = uid();
const right = alignPixelsToBottom(normalizePixels(pixels, size), size);
const left = alignPixelsToBottom(normalizePixels(leftPixels || mirrorPixels(right, size), size), size);
return {
id,
name,
category,
subtype,
size,
pixels: encodePixels(right),
faces: category === 'dynamic' ? { right: encodePixels(right), left: encodePixels(left) } : null,
parentAssetId: null,
originalAssetId: null,
createdAt: Date.now(),
author: 'Island Team',
meta: {
hasLight: Boolean(meta.hasLight),
lightPixels: (meta.lightPixels || []).map((p) => ({ ...p, c: p.c || nearestPaletteCode(meta.lightColor || '#ffd86a') })),
lightColor: nearestPaletteCode(meta.lightColor || '#ffd86a'),
depthPixels: encodeDepthPixels(normalizeDepthPixels(meta.depthPixels || [], size)),
door: meta.door || null
}
};
}
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('JSON exported.');
}
function importData() {
try {
const imported = JSON.parse(els.dataBox.value);
state = normalizeState(imported);
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();
hydrateAuthorUI();
selectedAssetId = state.assets[0]?.id ?? null;
saveState();
spriteCache.clear();
hydrateRuntime();
renderLibrary();
updateSelectedLabel();
toast('Local world reset.');
}
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++) out[i] = input[i] === '1' ? 1 : 0;
return out;
}
if (!Array.isArray(input)) return out;
for (let i = 0; i < Math.min(out.length, input.length); i++) out[i] = input[i] ? 1 : 0;
return out;
}
function encodeDepthPixels(input) {
return Array.from(input || []).map((v) => v ? '1' : '.').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) {
const depth = normalizeDepthPixels(asset.meta?.depthPixels || [], asset.size || 1);
return depth[y * asset.size + x] || 0;
}
function applyDepthToColor(hex, depth) {
if (!depth) return hex;
const rgb = parseHex(hex);
if (!rgb) return hex;
const amt = 34;
const r = Math.min(255, rgb.r + amt);
const g = Math.min(255, rgb.g + amt);
const b = Math.min(255, rgb.b + amt);
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${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 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 drawHouse() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 8, 8, 6, '#c57955');
rect(p, s, 6, 10, 3, 4, '#423044');
rect(p, s, 10, 9, 2, 2, '#ffdd7d');
rect(p, s, 4, 7, 8, 1, '#9b5163');
for (let y = 3; y <= 7; y++) {
for (let x = 3 + Math.abs(6 - y); x <= 12 - Math.abs(6 - y); x++) px(p, s, x, y, '#81495c');
}
rect(p, s, 2, 8, 12, 1, '#654154');
return p;
}
function drawTree() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 9, 2, 5, '#805032');
circle(p, s, 8, 5, 4, '#58b869');
circle(p, s, 5, 7, 3, '#4aa95d');
circle(p, s, 11, 7, 3, '#4aa95d');
circle(p, s, 8, 8, 4, '#63ca75');
px(p, s, 6, 5, '#8bef8f');
px(p, s, 10, 4, '#8bef8f');
return p;
}
function drawLantern() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 5, 2, 8, '#514b60');
rect(p, s, 5, 4, 6, 1, '#514b60');
rect(p, s, 6, 5, 4, 4, '#b8e8ff');
rect(p, s, 3, 13, 10, 1, '#86623f');
px(p, s, 5, 9, '#514b60'); px(p, s, 10, 9, '#514b60');
return p;
}
function drawHumanRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 3, 3, 3, '#e6b887');
rect(p, s, 6, 6, 5, 5, '#5d8bea');
px(p, s, 10, 4, '#273046');
rect(p, s, 5, 7, 1, 3, '#e6b887'); rect(p, s, 11, 7, 1, 3, '#e6b887');
rect(p, s, 6, 11, 2, 3, '#31354f'); rect(p, s, 9, 11, 2, 3, '#31354f');
rect(p, s, 6, 2, 5, 1, '#3a2b35');
return p;
}
function drawHumanLeft() { return mirrorPixels(drawHumanRight(), 16); }
function drawDogRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 4, 8, 7, 4, '#b77a46');
rect(p, s, 10, 7, 3, 3, '#c98952');
px(p, s, 12, 8, '#2f1e17');
rect(p, s, 5, 12, 1, 2, '#754b31'); rect(p, s, 9, 12, 1, 2, '#754b31');
px(p, s, 3, 8, '#b77a46'); px(p, s, 2, 7, '#b77a46');
return p;
}
function drawDogLeft() { return mirrorPixels(drawDogRight(), 16); }
function drawFishRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 5, 7, 6, 3, '#4bd7ff');
px(p, s, 11, 8, '#e7fbff'); px(p, s, 4, 7, '#258ac0'); px(p, s, 3, 6, '#258ac0'); px(p, s, 3, 10, '#258ac0');
px(p, s, 6, 6, '#90edff'); px(p, s, 8, 10, '#90edff');
return p;
}
function drawFishLeft() { return mirrorPixels(drawFishRight(), 16); }
function drawBirdRight() {
const s = 16, p = blankPixels(s);
rect(p, s, 7, 6, 3, 3, '#f4d45f');
px(p, s, 10, 7, '#f09062');
rect(p, s, 4, 6, 3, 1, '#6da9ef');
rect(p, s, 10, 5, 3, 1, '#6da9ef');
px(p, s, 8, 5, '#242434');
return p;
}
function drawBirdLeft() { return mirrorPixels(drawBirdRight(), 16); }
bootstrap();
})();